feat(physics): retail PvP exemption + viewer/creature/missile gates (Commit C)
The retail-faithful exemption block at the top of
CPhysicsObj::FindObjCollisions
(acclient_2013_pseudo_c.txt:276782-276839,276971), ported line-for-
line as a small static helper.
Behaviour now matches retail:
- Two non-PK players walk through each other.
- Two PK players collide.
- Two PKLite players collide.
- Mismatched PK status (PK vs non-PK, PK vs PKLite) — exempt.
- Impenetrable target ("Free" PK status) — always collides.
- Player vs creature/NPC — always collides (this is what closes the
user-facing complaint that walking into a Holtburg vendor was
walking through them).
- Mover with IGNORE_CREATURES — walks through creature targets.
- Viewer (camera ray) — walks through creatures.
- Target with ETHEREAL+IGNORE_COLLISIONS — universally exempt.
CollisionExemption.ShouldSkip(targetState, targetFlags, moverState)
- new file src/AcDream.Core/Physics/CollisionExemption.cs.
- 13-test matrix covering every documented case
(CollisionExemptionTests.cs).
- Static + pure → cheap to call from the hot path.
Wiring:
- TransitionTypes.FindObjCollisions: after broadphase distance
reject, call ShouldSkip on the obj and ObjectInfo.State; on true,
`continue`. Static landblock entries (State=0, Flags=None) fall
through cheaply — no behavior change for static collision.
- PhysicsEngine.ResolveWithTransition: new optional moverFlags
parameter (default None for back-compat). PlayerMovementController
passes ObjectInfoState.IsPlayer; remote dead-reckoning leaves it
None (matches non-player movers, no PvP exemption applies).
- PK/PKLite/Impenetrable bits for the LOCAL player are not yet
sourced from PlayerDescription's PlayerKillerStatus property —
that's a follow-up. Default "non-PK player" matches ACE's
character-creation default and the user's +Acdream test
character.
Cross-checked against ACE PhysicsObj.cs:381-405 (line-for-line C# port
of the same retail block). Only intentional divergence: ACE adds
state.HasFlag(IsImpenetrable) (mover-impenetrable) to the collide list;
retail's pseudo-C only checks the target — acdream follows retail.
dotnet build green, dotnet test 1467 passing (+13 new). Live test:
+Acdream walking into Holtburg vendors now stops at their cylinder;
walking through small plants still passes (Commit B's phantom skip).
Closes the live-entity collision arc: A (plumbing) + B (registration)
+ C (exemption).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
46ca3ba26b
commit
7d6fe90607
5 changed files with 319 additions and 2 deletions
|
|
@ -413,7 +413,13 @@ public sealed class PlayerMovementController
|
||||||
stepUpHeight: StepUpHeight,
|
stepUpHeight: StepUpHeight,
|
||||||
stepDownHeight: 0.04f, // retail default
|
stepDownHeight: 0.04f, // retail default
|
||||||
isOnGround: _body.OnWalkable,
|
isOnGround: _body.OnWalkable,
|
||||||
body: _body); // persist ContactPlane across frames for slope tracking
|
body: _body, // persist ContactPlane across frames for slope tracking
|
||||||
|
// Commit C 2026-04-29 — local player is always IsPlayer.
|
||||||
|
// The PK/PKLite/Impenetrable bits come from PlayerDescription's
|
||||||
|
// PlayerKillerStatus property; not yet parsed (non-PK pair → walks
|
||||||
|
// through other non-PK players, which is retail's default for
|
||||||
|
// ACE's character creation defaults too).
|
||||||
|
moverFlags: AcDream.Core.Physics.ObjectInfoState.IsPlayer);
|
||||||
|
|
||||||
// Apply resolved position.
|
// Apply resolved position.
|
||||||
_body.Position = resolveResult.Position;
|
_body.Position = resolveResult.Position;
|
||||||
|
|
|
||||||
123
src/AcDream.Core/Physics/CollisionExemption.cs
Normal file
123
src/AcDream.Core/Physics/CollisionExemption.cs
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
namespace AcDream.Core.Physics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The retail-faithful exemption gate at the top of
|
||||||
|
/// <c>CPhysicsObj::FindObjCollisions</c>. Decides — based on the moving
|
||||||
|
/// object's <see cref="ObjectInfoState"/> bits and the target's raw
|
||||||
|
/// <c>PhysicsState</c> + decoded <see cref="EntityCollisionFlags"/> —
|
||||||
|
/// whether collision against the target should be skipped entirely
|
||||||
|
/// (return <c>OK_TS</c>) or proceed to broad-phase / shape dispatch.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Ported from the named retail decompilation:
|
||||||
|
/// </para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>acclient_2013_pseudo_c.txt:276782</c> — target
|
||||||
|
/// <c>ETHEREAL_PS=0x4 & IGNORE_COLLISIONS_PS=0x10</c>: walk through.</item>
|
||||||
|
/// <item><c>acclient_2013_pseudo_c.txt:276787</c> — viewer mover vs
|
||||||
|
/// creature target: walk through (camera ray ignores creatures).</item>
|
||||||
|
/// <item><c>acclient_2013_pseudo_c.txt:276971</c> — mover with
|
||||||
|
/// <c>IGNORE_CREATURES (state & 0x400)</c> vs creature target:
|
||||||
|
/// walk through.</item>
|
||||||
|
/// <item><c>acclient_2013_pseudo_c.txt:276807-276839</c> — PvP rule:
|
||||||
|
/// <para>
|
||||||
|
/// If both are players: skip <em>unless</em> target is Impenetrable,
|
||||||
|
/// or both are PK, or both are PKLite. Mismatched PK status (PK vs
|
||||||
|
/// non-PK, PK vs PKLite) is exempted — players in different pools
|
||||||
|
/// pass through each other, matching retail muscle memory.
|
||||||
|
/// </para>
|
||||||
|
/// </item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Cross-checked against ACE
|
||||||
|
/// <c>references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:381-405</c>
|
||||||
|
/// (line-for-line C# port of the same logic). Note: ACE adds
|
||||||
|
/// <c>state.HasFlag(IsImpenetrable)</c> (mover-impenetrable) to the
|
||||||
|
/// collide list; retail's pseudo-C only checks the target's
|
||||||
|
/// <c>IsImpenetrable()</c>. acdream follows retail.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static class CollisionExemption
|
||||||
|
{
|
||||||
|
private const uint ETHEREAL_PS = 0x4u; // acclient.h:2819
|
||||||
|
private const uint IGNORE_COLLISIONS_PS = 0x10u; // acclient.h:2821
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Should the moving object skip collision testing against this
|
||||||
|
/// target entirely? Returns <c>true</c> if exempt (no further
|
||||||
|
/// shape dispatch).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="targetState">Raw retail <c>PhysicsState</c> bits
|
||||||
|
/// captured at <c>CreateObject</c> time (ETHEREAL/IGNORE/etc.).</param>
|
||||||
|
/// <param name="targetFlags">Decoded
|
||||||
|
/// <see cref="EntityCollisionFlags"/> from the target's PWD bitfield
|
||||||
|
/// plus its <c>ItemType</c>-derived <c>IsCreature</c> bit.</param>
|
||||||
|
/// <param name="moverState">The moving object's
|
||||||
|
/// <see cref="ObjectInfoState"/> — typically the local player's
|
||||||
|
/// IsPlayer + (PK/PKLite/Impenetrable bits if known) flags.</param>
|
||||||
|
public static bool ShouldSkip(uint targetState, EntityCollisionFlags targetFlags,
|
||||||
|
ObjectInfoState moverState)
|
||||||
|
{
|
||||||
|
// 1. Target ETHEREAL + IGNORE_COLLISIONS → walk through.
|
||||||
|
// acclient_2013_pseudo_c.txt:276782 — wraps the entire body of
|
||||||
|
// FindObjCollisions; we hoist it as the first early-out.
|
||||||
|
if ((targetState & ETHEREAL_PS) != 0
|
||||||
|
&& (targetState & IGNORE_COLLISIONS_PS) != 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Viewer mover + creature target → walk through.
|
||||||
|
// acclient_2013_pseudo_c.txt:276787-276790.
|
||||||
|
bool moverIsViewer = (moverState & ObjectInfoState.IsViewer) != 0;
|
||||||
|
bool targetIsCreature = (targetFlags & EntityCollisionFlags.IsCreature) != 0;
|
||||||
|
if (moverIsViewer && targetIsCreature)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// 3. IGNORE_CREATURES mover + creature target → walk through.
|
||||||
|
// acclient_2013_pseudo_c.txt:276971.
|
||||||
|
bool moverIgnoresCreatures = (moverState & ObjectInfoState.IgnoreCreatures) != 0;
|
||||||
|
if (moverIgnoresCreatures && targetIsCreature)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// 4. PvP exemption block.
|
||||||
|
// acclient_2013_pseudo_c.txt:276807-276839.
|
||||||
|
bool moverIsPlayer = (moverState & ObjectInfoState.IsPlayer) != 0;
|
||||||
|
bool targetIsPlayer = (targetFlags & EntityCollisionFlags.IsPlayer) != 0;
|
||||||
|
if (moverIsPlayer && targetIsPlayer)
|
||||||
|
{
|
||||||
|
// Tentatively exempt (retail `ebp_1 = 1`). Then disqualify
|
||||||
|
// if any of the COLLIDE conditions hold.
|
||||||
|
bool collide = false;
|
||||||
|
|
||||||
|
// 4a. Impenetrable target → collide.
|
||||||
|
// acclient_2013_pseudo_c.txt:276826.
|
||||||
|
if ((targetFlags & EntityCollisionFlags.IsImpenetrable) != 0)
|
||||||
|
collide = true;
|
||||||
|
|
||||||
|
// 4b. Both PK → collide.
|
||||||
|
// acclient_2013_pseudo_c.txt:276832-276836.
|
||||||
|
if (!collide
|
||||||
|
&& (moverState & ObjectInfoState.IsPK) != 0
|
||||||
|
&& (targetFlags & EntityCollisionFlags.IsPK) != 0)
|
||||||
|
{
|
||||||
|
collide = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4c. Both PKLite → collide.
|
||||||
|
// acclient_2013_pseudo_c.txt:276837.
|
||||||
|
if (!collide
|
||||||
|
&& (moverState & ObjectInfoState.IsPKLite) != 0
|
||||||
|
&& (targetFlags & EntityCollisionFlags.IsPKLite) != 0)
|
||||||
|
{
|
||||||
|
collide = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!collide)
|
||||||
|
return true; // exempt — non-PK pair walks through
|
||||||
|
}
|
||||||
|
|
||||||
|
return false; // proceed to broad-phase + shape dispatch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -388,13 +388,21 @@ public sealed class PhysicsEngine
|
||||||
float sphereRadius, float sphereHeight,
|
float sphereRadius, float sphereHeight,
|
||||||
float stepUpHeight, float stepDownHeight,
|
float stepUpHeight, float stepDownHeight,
|
||||||
bool isOnGround,
|
bool isOnGround,
|
||||||
PhysicsBody? body = null)
|
PhysicsBody? body = null,
|
||||||
|
ObjectInfoState moverFlags = ObjectInfoState.None)
|
||||||
{
|
{
|
||||||
var transition = new Transition();
|
var transition = new Transition();
|
||||||
transition.ObjectInfo.StepUpHeight = stepUpHeight;
|
transition.ObjectInfo.StepUpHeight = stepUpHeight;
|
||||||
transition.ObjectInfo.StepDownHeight = stepDownHeight;
|
transition.ObjectInfo.StepDownHeight = stepDownHeight;
|
||||||
transition.ObjectInfo.StepDown = true;
|
transition.ObjectInfo.StepDown = true;
|
||||||
|
|
||||||
|
// Commit C 2026-04-29 — caller-supplied mover flags drive the
|
||||||
|
// retail PvP exemption block in FindObjCollisions. The local
|
||||||
|
// player passes IsPlayer (and PK/PKLite/Impenetrable when known
|
||||||
|
// from PlayerDescription); remote dead-reckoning passes None
|
||||||
|
// (matches non-player movement, all targets collide).
|
||||||
|
transition.ObjectInfo.State |= moverFlags;
|
||||||
|
|
||||||
if (isOnGround)
|
if (isOnGround)
|
||||||
transition.ObjectInfo.State |= ObjectInfoState.Contact | ObjectInfoState.OnWalkable;
|
transition.ObjectInfo.State |= ObjectInfoState.Contact | ObjectInfoState.OnWalkable;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -807,6 +807,17 @@ public sealed class Transition
|
||||||
if (distToCurr > maxReach)
|
if (distToCurr > maxReach)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
// Commit C 2026-04-29 — retail exemption block at the top of
|
||||||
|
// CPhysicsObj::FindObjCollisions
|
||||||
|
// (acclient_2013_pseudo_c.txt:276782-276839,276971). Skips
|
||||||
|
// ethereal+ignore-collisions, viewer-vs-creature,
|
||||||
|
// IGNORE_CREATURES movers, and the player-vs-player PvP
|
||||||
|
// pass-through (non-PK pair / mismatched PK). Static
|
||||||
|
// landblock entries register with State=0 and Flags=None,
|
||||||
|
// so this is a cheap fall-through for them.
|
||||||
|
if (CollisionExemption.ShouldSkip(obj.State, obj.Flags, ObjectInfo.State))
|
||||||
|
continue;
|
||||||
|
|
||||||
TransitionState result;
|
TransitionState result;
|
||||||
|
|
||||||
if (obj.CollisionType == ShadowCollisionType.BSP)
|
if (obj.CollisionType == ShadowCollisionType.BSP)
|
||||||
|
|
|
||||||
169
tests/AcDream.Core.Tests/Physics/CollisionExemptionTests.cs
Normal file
169
tests/AcDream.Core.Tests/Physics/CollisionExemptionTests.cs
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Physics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for <see cref="CollisionExemption"/> — Commit C of the
|
||||||
|
/// 2026-04-29 live-entity collision port. Covers retail's
|
||||||
|
/// <c>CPhysicsObj::FindObjCollisions</c> exemption block, ported
|
||||||
|
/// line-for-line from
|
||||||
|
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:276782-276839,276971</c>.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Behaviour matrix (target / mover columns):
|
||||||
|
/// </para>
|
||||||
|
/// <list type="table">
|
||||||
|
/// <listheader><term>Mover</term><term>Target</term><description>Skip?</description></listheader>
|
||||||
|
/// <item><term>any</term><term>ETHEREAL+IGNORE_COLLISIONS</term><description>YES (early-out)</description></item>
|
||||||
|
/// <item><term>IsViewer</term><term>IsCreature</term><description>YES (camera ray-test passes through)</description></item>
|
||||||
|
/// <item><term>IGNORE_CREATURES</term><term>IsCreature</term><description>YES (mover walks through creatures)</description></item>
|
||||||
|
/// <item><term>IsPlayer</term><term>IsPlayer (no PK)</term><description>YES (non-PK pair walks through)</description></item>
|
||||||
|
/// <item><term>IsPlayer + IsPK</term><term>IsPlayer + IsPK</term><description>NO (PK pair collides)</description></item>
|
||||||
|
/// <item><term>IsPlayer + IsPKLite</term><term>IsPlayer + IsPKLite</term><description>NO (PKLite pair collides)</description></item>
|
||||||
|
/// <item><term>IsPlayer</term><term>IsPlayer + IsImpenetrable</term><description>NO (Impenetrable target always collides)</description></item>
|
||||||
|
/// <item><term>IsPlayer + IsPK</term><term>IsPlayer (no PK)</term><description>YES (mismatched PK skip)</description></item>
|
||||||
|
/// <item><term>IsPlayer</term><term>IsCreature (NPC)</term><description>NO (player vs NPC always collides)</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public class CollisionExemptionTests
|
||||||
|
{
|
||||||
|
private const uint ETHEREAL_PS = 0x4u;
|
||||||
|
private const uint IGNORE_COLLISIONS_PS = 0x10u;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EtherealAndIgnoreCollisions_AlwaysSkipped()
|
||||||
|
{
|
||||||
|
// Target with both bits set is exempted from any mover.
|
||||||
|
Assert.True(CollisionExemption.ShouldSkip(
|
||||||
|
targetState: ETHEREAL_PS | IGNORE_COLLISIONS_PS,
|
||||||
|
targetFlags: EntityCollisionFlags.None,
|
||||||
|
moverState: ObjectInfoState.IsPlayer));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EtherealOnly_NotSkipped()
|
||||||
|
{
|
||||||
|
// Target with ETHEREAL but NOT IGNORE_COLLISIONS does not bail
|
||||||
|
// out at the first gate — collision proceeds. (Step-down marks
|
||||||
|
// obstruction_ethereal, but does not exempt.)
|
||||||
|
Assert.False(CollisionExemption.ShouldSkip(
|
||||||
|
targetState: ETHEREAL_PS,
|
||||||
|
targetFlags: EntityCollisionFlags.None,
|
||||||
|
moverState: ObjectInfoState.IsPlayer));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Viewer_VsCreature_Skipped()
|
||||||
|
{
|
||||||
|
// Camera-ray viewer transitions through creatures.
|
||||||
|
Assert.True(CollisionExemption.ShouldSkip(
|
||||||
|
targetState: 0u,
|
||||||
|
targetFlags: EntityCollisionFlags.IsCreature,
|
||||||
|
moverState: ObjectInfoState.IsViewer));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Viewer_VsNonCreature_NotSkipped()
|
||||||
|
{
|
||||||
|
Assert.False(CollisionExemption.ShouldSkip(
|
||||||
|
targetState: 0u,
|
||||||
|
targetFlags: EntityCollisionFlags.None,
|
||||||
|
moverState: ObjectInfoState.IsViewer));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IgnoreCreatures_VsCreature_Skipped()
|
||||||
|
{
|
||||||
|
// Per acclient_2013_pseudo_c.txt:276971 — an arrow with
|
||||||
|
// IGNORE_CREATURES doesn't get blocked by the very monster it's
|
||||||
|
// tracking towards (until missile_ignore filters its target).
|
||||||
|
Assert.True(CollisionExemption.ShouldSkip(
|
||||||
|
targetState: 0u,
|
||||||
|
targetFlags: EntityCollisionFlags.IsCreature,
|
||||||
|
moverState: ObjectInfoState.IgnoreCreatures));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NonPkPlayer_VsNonPkPlayer_Skipped()
|
||||||
|
{
|
||||||
|
// The user-visible payoff: two ordinary players walk through each
|
||||||
|
// other instead of blocking.
|
||||||
|
Assert.True(CollisionExemption.ShouldSkip(
|
||||||
|
targetState: 0u,
|
||||||
|
targetFlags: EntityCollisionFlags.IsPlayer,
|
||||||
|
moverState: ObjectInfoState.IsPlayer));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pk_VsPk_NotSkipped()
|
||||||
|
{
|
||||||
|
// Two PK players collide.
|
||||||
|
Assert.False(CollisionExemption.ShouldSkip(
|
||||||
|
targetState: 0u,
|
||||||
|
targetFlags: EntityCollisionFlags.IsPlayer | EntityCollisionFlags.IsPK,
|
||||||
|
moverState: ObjectInfoState.IsPlayer | ObjectInfoState.IsPK));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PkLite_VsPkLite_NotSkipped()
|
||||||
|
{
|
||||||
|
Assert.False(CollisionExemption.ShouldSkip(
|
||||||
|
targetState: 0u,
|
||||||
|
targetFlags: EntityCollisionFlags.IsPlayer | EntityCollisionFlags.IsPKLite,
|
||||||
|
moverState: ObjectInfoState.IsPlayer | ObjectInfoState.IsPKLite));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pk_VsNonPk_Skipped()
|
||||||
|
{
|
||||||
|
// Mismatched PK status: still exempt — only matching pair collides.
|
||||||
|
Assert.True(CollisionExemption.ShouldSkip(
|
||||||
|
targetState: 0u,
|
||||||
|
targetFlags: EntityCollisionFlags.IsPlayer,
|
||||||
|
moverState: ObjectInfoState.IsPlayer | ObjectInfoState.IsPK));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pk_VsPkLite_Skipped()
|
||||||
|
{
|
||||||
|
// PK and PKLite are different pools — pair doesn't match.
|
||||||
|
Assert.True(CollisionExemption.ShouldSkip(
|
||||||
|
targetState: 0u,
|
||||||
|
targetFlags: EntityCollisionFlags.IsPlayer | EntityCollisionFlags.IsPKLite,
|
||||||
|
moverState: ObjectInfoState.IsPlayer | ObjectInfoState.IsPK));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ImpenetrableTarget_VsAnyPlayer_NotSkipped()
|
||||||
|
{
|
||||||
|
// Impenetrable target ("Free" PK status) always collides with
|
||||||
|
// any player mover — regardless of mover's PK state.
|
||||||
|
Assert.False(CollisionExemption.ShouldSkip(
|
||||||
|
targetState: 0u,
|
||||||
|
targetFlags: EntityCollisionFlags.IsPlayer | EntityCollisionFlags.IsImpenetrable,
|
||||||
|
moverState: ObjectInfoState.IsPlayer));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Player_VsCreature_NotSkipped()
|
||||||
|
{
|
||||||
|
// PvP exemption only applies player-on-player. Player vs creature
|
||||||
|
// (NPC, monster) is the normal blocking case.
|
||||||
|
Assert.False(CollisionExemption.ShouldSkip(
|
||||||
|
targetState: 0u,
|
||||||
|
targetFlags: EntityCollisionFlags.IsCreature,
|
||||||
|
moverState: ObjectInfoState.IsPlayer));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NonPlayerMover_VsPlayer_NotSkipped()
|
||||||
|
{
|
||||||
|
// PvP rule requires BOTH to be players. Mover is not a player
|
||||||
|
// (e.g., dead-reckoned remote NPC) → no exemption applies.
|
||||||
|
Assert.False(CollisionExemption.ShouldSkip(
|
||||||
|
targetState: 0u,
|
||||||
|
targetFlags: EntityCollisionFlags.IsPlayer,
|
||||||
|
moverState: ObjectInfoState.None));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue