acdream/tests/AcDream.Core.Tests/Physics/CollisionExemptionTests.cs
Erik a6e4b5709f fix(phys L.2g slice 1b): widen CollisionExemption to ETHEREAL alone
B.4b visual test confirmed the L.2g slice 1 handoff's open question:
ACE's Door.Open() broadcasts state=0x0001000C (HasPhysicsBSP |
Ethereal | ReportCollisions), NOT the state=0x14+ that retail servers
send (Ethereal | IgnoreCollisions). The L.2g pipeline correctly
mutates ShadowObjectRegistry with the new state, but
CollisionExemption.ShouldSkip required both bits and the door stayed
solid.

Retail (acclient_2013_pseudo_c.txt:276782) wraps FindObjCollisions in
`if NOT (state & ETHEREAL && state & IGNORE_COLLISIONS)`. ETHEREAL
alone takes a different retail path at line 276795 that sets
sphere_path.obstruction_ethereal = 1 and lets downstream movement
allow passage despite the contact. We haven't ported that downstream
path yet.

Pragmatic shortcut: widen the early-out to ETHEREAL alone so doors
become passable when ACE flips the bit. Retail-server broadcasts
still hit the same branch correctly (both bits set implies ETHEREAL).
Compatible with both server styles.

Renames test EtherealOnly_NotSkipped -> EtherealOnly_Skipped and
flips its assertion. 13 CollisionExemption tests pass; full suite
1046 pass / 8 pre-existing baseline fail (unchanged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:27:06 +02:00

173 lines
7 KiB
C#

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_Skipped()
{
// L.2g slice 1b (2026-05-13): ETHEREAL alone exempts collision.
// Retail (acclient_2013_pseudo_c.txt:276782) required both bits,
// but ACE's Door.Open() broadcasts ETHEREAL alone — observed
// live: state=0x0001000C (HasPhysicsBSP | Ethereal | ReportCollisions).
// Pragmatic shortcut: widen the early-out to ETHEREAL alone so
// doors become passable when ACE flips the bit. Retail-server
// broadcasts (state=0x14+) still hit the same branch correctly.
Assert.True(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));
}
}