using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Items;
using AcDream.Core.Physics;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using Xunit;
namespace AcDream.Core.Tests.Physics;
///
/// Conformance tests for AP-71/AP-129 (Campaign P Slice P4, 2026-07-30 —
/// including the 2026-07-30 Opus review fix) — retail
/// CObjCell::check_entry_restrictions (named-retail pc:308873-308912,
/// 0x0052b6d0) plus ACCWeenieObject::CanMoveInto (pc:407982-408056)
/// and RestrictionDB::IsAllowedIn (pc:444493-444516), ported as
/// and wired at the head of
/// the indoor branch of Transition.FindEnvCollisions
/// (src/AcDream.Core/Physics/TransitionTypes.cs).
///
///
/// Retail gate order: NPCs/props (not a player) bypass entirely; a mover
/// whose PWD bitfield grants CanBypassMoveRestrictions (BF_ADMIN &
/// BF_IMMUNE_CELL_RESTRICTIONS) bypasses; an ordinary cell (no
/// restriction_obj authored) is a no-op; otherwise retail resolves
/// the restriction weenie ( is acdream's
/// GetObjectA equivalent) and asks CanMoveInto: an unresolved
/// object fails CLOSED (retail's own fallback, pc:704-716); a resolved
/// object with no owner (or the mover IS the owner) admits; a resolved
/// object with no guest list (_db == 0) admits; otherwise
/// RestrictionDB::IsAllowedIn decides (open-to-public, shared
/// allegiance monarch, or explicit guest-table membership).
///
///
///
/// AP-129 review-fix context: 103,766 of 729,888 installed EnvCells (1,293
/// landblocks — the housing estate) carry a baked RestrictionObj
/// (RestrictionObjPrevalenceInspectionTests). Shipping the gate
/// unconditionally fail-closed for every one of those cells would lock
/// every house interior for every player, including its own owner — this
/// suite's "resolved" scenarios are what prevents that regression.
///
///
public sealed class Ap71EntryRestrictionGateTests
{
private const uint RestrictionObjGuid = 0x80001234u;
private const uint MoverGuid = 0x50000001u;
[Fact]
public void OrdinaryCell_NoRestrictionObj_IsNoOp_ForPlayer()
{
var mover = new ObjectInfo { State = ObjectInfoState.IsPlayer };
Assert.Equal(TransitionState.OK, mover.CheckEntryRestrictions(cellRestrictionObj: 0, objects: null));
}
[Fact]
public void OrdinaryCell_NoRestrictionObj_IsNoOp_ForNonPlayer()
{
// NPCs/props aren't players; confirms the gate is a genuine no-op for
// the overwhelmingly common (non-house) content regardless of mover kind.
var mover = new ObjectInfo { State = ObjectInfoState.None };
Assert.Equal(TransitionState.OK, mover.CheckEntryRestrictions(cellRestrictionObj: 0, objects: null));
}
[Fact]
public void RestrictedCell_NonPlayerMover_Bypasses()
{
// Retail's (state & 0x100) == 0 -> OK early return: NPCs/props never
// gated by house restrictions regardless of the cell's restriction_obj.
var mover = new ObjectInfo { State = ObjectInfoState.None };
Assert.Equal(
TransitionState.OK,
mover.CheckEntryRestrictions(RestrictionObjGuid, objects: null));
}
[Fact]
public void RestrictedCell_PlayerMover_UnresolvedRestrictionObject_FailsClosed()
{
// A restricted cell, a player mover, no CanBypassMoveRestrictions bit,
// and no way to resolve the restriction object (objects table null,
// or the object simply hasn't arrived via CreateObject yet) ->
// Collided, exactly retail's own fallback when GetObjectA(restriction_obj)
// returns null (pc:704-716 fallthrough to COLLIDED_TS).
var mover = new ObjectInfo { State = ObjectInfoState.IsPlayer, SelfEntityId = MoverGuid };
Assert.Equal(
TransitionState.Collided,
mover.CheckEntryRestrictions(RestrictionObjGuid, objects: null));
}
[Fact]
public void RestrictedCell_PlayerMover_ResolvedButObjectsTableHasNoRow_FailsClosed()
{
// A non-null table that simply doesn't (yet) know this specific
// restriction object -- same fail-closed outcome, proven with a real
// (empty) ClientObjectTable rather than a null one.
var mover = new ObjectInfo { State = ObjectInfoState.IsPlayer, SelfEntityId = MoverGuid };
var objects = new ClientObjectTable();
Assert.Equal(
TransitionState.Collided,
mover.CheckEntryRestrictions(RestrictionObjGuid, objects));
}
[Fact]
public void RestrictedCell_PlayerMover_CanBypassMoveRestrictions_PassesThrough()
{
// BF_ADMIN & BF_IMMUNE_CELL_RESTRICTIONS both set on the mover's own
// PWD bitfield (via EntityCollisionFlagsExt.ToMoverState) -> OK,
// regardless of whether the restriction object could even resolve.
var mover = new ObjectInfo
{
State = ObjectInfoState.IsPlayer | ObjectInfoState.CanBypassMoveRestrictions,
SelfEntityId = MoverGuid,
};
Assert.Equal(
TransitionState.OK,
mover.CheckEntryRestrictions(RestrictionObjGuid, objects: null));
}
// ── AP-129 review fix: CanMoveInto / IsAllowedIn resolved scenarios ────
private static ClientObjectTable MakeObjectsWithRestrictionObject(
uint? houseOwnerId,
HouseRestrictionRecord? restrictions)
{
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = RestrictionObjGuid,
HouseOwnerId = houseOwnerId,
Restrictions = restrictions,
});
return objects;
}
[Fact]
public void ResolvedUnownedHouse_NoOwnerNoRestrictions_Admits()
{
// HouseOwnerId absent (0) and no RestrictionDB at all -- retail:
// house_owner_iid==0 -> CanMoveInto returns true immediately.
var mover = new ObjectInfo { State = ObjectInfoState.IsPlayer, SelfEntityId = MoverGuid };
var objects = MakeObjectsWithRestrictionObject(houseOwnerId: null, restrictions: null);
Assert.Equal(
TransitionState.OK,
mover.CheckEntryRestrictions(RestrictionObjGuid, objects));
}
[Fact]
public void ResolvedHouse_MoverIsOwner_Admits()
{
// HouseOwnerId == mover's own guid -- retail: house_owner_iid==mover.id
// -> CanMoveInto returns true, even with a closed guest-only RestrictionDB
// that would otherwise exclude this exact mover.
var mover = new ObjectInfo { State = ObjectInfoState.IsPlayer, SelfEntityId = MoverGuid };
var closedList = new HouseRestrictionRecord(
OpenToPublic: false,
AllegianceMonarchId: 0,
Guests: new Dictionary());
var objects = MakeObjectsWithRestrictionObject(houseOwnerId: MoverGuid, restrictions: closedList);
Assert.Equal(
TransitionState.OK,
mover.CheckEntryRestrictions(RestrictionObjGuid, objects));
}
[Fact]
public void ResolvedHouse_NoRestrictionDb_Admits()
{
// Owned by someone else, but Restrictions is null (retail _db == 0)
// -- "no list" is open, matching a retail client that hasn't been
// sent House_UpdateRestrictions yet.
var mover = new ObjectInfo { State = ObjectInfoState.IsPlayer, SelfEntityId = MoverGuid };
const uint otherOwnerId = 0x50000099u;
var objects = MakeObjectsWithRestrictionObject(houseOwnerId: otherOwnerId, restrictions: null);
Assert.Equal(
TransitionState.OK,
mover.CheckEntryRestrictions(RestrictionObjGuid, objects));
}
[Fact]
public void ResolvedHouse_PresentListExcludesMover_Blocks()
{
// Owned by someone else, closed (not open-to-public), no shared
// allegiance monarch, and the mover's guid is NOT in the guest table
// -- retail RestrictionDB::IsAllowedIn falls through to deny.
var mover = new ObjectInfo { State = ObjectInfoState.IsPlayer, SelfEntityId = MoverGuid };
const uint otherOwnerId = 0x50000099u;
var restrictions = new HouseRestrictionRecord(
OpenToPublic: false,
AllegianceMonarchId: 0,
Guests: new Dictionary { [0x50000002u] = 0u }); // a DIFFERENT guest
var objects = MakeObjectsWithRestrictionObject(houseOwnerId: otherOwnerId, restrictions: restrictions);
Assert.Equal(
TransitionState.Collided,
mover.CheckEntryRestrictions(RestrictionObjGuid, objects));
}
[Fact]
public void ResolvedHouse_PresentListIncludesMover_Admits()
{
// Same closed house, but the mover's own guid IS a guest-table entry
// -- RestrictionDB::IsAllowedIn finds it -> admit. Permission VALUE
// (0=dwelling,1=storage) is not consulted for entry, matching retail.
var mover = new ObjectInfo { State = ObjectInfoState.IsPlayer, SelfEntityId = MoverGuid };
const uint otherOwnerId = 0x50000099u;
var restrictions = new HouseRestrictionRecord(
OpenToPublic: false,
AllegianceMonarchId: 0,
Guests: new Dictionary { [MoverGuid] = 1u }); // storage guest
var objects = MakeObjectsWithRestrictionObject(houseOwnerId: otherOwnerId, restrictions: restrictions);
Assert.Equal(
TransitionState.OK,
mover.CheckEntryRestrictions(RestrictionObjGuid, objects));
}
[Fact]
public void ResolvedHouse_OpenToPublic_AdmitsEvenWithoutGuestEntry()
{
// Flags bit 0 set (open to public) -- everyone in, regardless of the
// guest table or allegiance.
var mover = new ObjectInfo { State = ObjectInfoState.IsPlayer, SelfEntityId = MoverGuid };
const uint otherOwnerId = 0x50000099u;
var restrictions = new HouseRestrictionRecord(
OpenToPublic: true,
AllegianceMonarchId: 0,
Guests: new Dictionary());
var objects = MakeObjectsWithRestrictionObject(houseOwnerId: otherOwnerId, restrictions: restrictions);
Assert.Equal(
TransitionState.OK,
mover.CheckEntryRestrictions(RestrictionObjGuid, objects));
}
[Fact]
public void ResolvedHouse_MoverSharesAllegianceMonarch_Admits()
{
// Closed, mover not in the guest table, but the mover's OWN MonarchId
// (retail mover_weenie+0x128) matches the house's AllegianceMonarchId
// -- RestrictionDB::IsAllowedIn's allegiance branch admits.
var mover = new ObjectInfo { State = ObjectInfoState.IsPlayer, SelfEntityId = MoverGuid };
const uint otherOwnerId = 0x50000099u;
const uint monarchId = 0x50000500u;
var restrictions = new HouseRestrictionRecord(
OpenToPublic: false,
AllegianceMonarchId: monarchId,
Guests: new Dictionary());
var objects = MakeObjectsWithRestrictionObject(houseOwnerId: otherOwnerId, restrictions: restrictions);
objects.AddOrUpdate(new ClientObject { ObjectId = MoverGuid, MonarchId = monarchId });
Assert.Equal(
TransitionState.OK,
mover.CheckEntryRestrictions(RestrictionObjGuid, objects));
}
// ── PWD-bitfield decode (mirrors the existing EntityCollisionFlagsTests
// pattern for IsPK/IsPKLite/IsImpenetrable) ─────────────────────────────
[Fact]
public void FromPwdBitfield_OnlyAdminBit_DoesNotGrantBypass()
{
// BF_ADMIN alone (0x100000) is not sufficient — retail ANDs both bits.
var flags = EntityCollisionFlagsExt.FromPwdBitfield(0x100000u);
Assert.False(flags.HasFlag(EntityCollisionFlags.CanBypassMoveRestrictions));
}
[Fact]
public void FromPwdBitfield_OnlyImmuneCellRestrictionsBit_DoesNotGrantBypass()
{
// BF_IMMUNE_CELL_RESTRICTIONS alone (0x400000) is not sufficient either.
var flags = EntityCollisionFlagsExt.FromPwdBitfield(0x400000u);
Assert.False(flags.HasFlag(EntityCollisionFlags.CanBypassMoveRestrictions));
}
[Fact]
public void FromPwdBitfield_BothAdminAndImmuneCellRestrictionsBits_GrantsBypass()
{
uint bitfield = 0x100000u | 0x400000u;
var flags = EntityCollisionFlagsExt.FromPwdBitfield(bitfield);
Assert.True(flags.HasFlag(EntityCollisionFlags.CanBypassMoveRestrictions));
}
[Fact]
public void ToMoverState_CanBypassMoveRestrictions_TranslatesIndependently()
{
Assert.Equal(
ObjectInfoState.CanBypassMoveRestrictions,
EntityCollisionFlags.CanBypassMoveRestrictions.ToMoverState());
}
[Fact]
public void FromPwdBitfield_ThenToMoverState_EndToEnd_AdminBypassesRestriction()
{
// Full pipeline: a wire bitfield with BF_PLAYER | BF_ADMIN |
// BF_IMMUNE_CELL_RESTRICTIONS decodes through FromPwdBitfield ->
// ToMoverState -> ORs into moverFlags -> ObjectInfo.State ->
// CheckEntryRestrictions passes through a restricted cell.
uint bitfield = 0x8u | 0x100000u | 0x400000u;
ObjectInfoState moverState =
ObjectInfoState.IsPlayer | EntityCollisionFlagsExt.FromPwdBitfield(bitfield).ToMoverState();
var mover = new ObjectInfo { State = moverState, SelfEntityId = MoverGuid };
Assert.Equal(
TransitionState.OK,
mover.CheckEntryRestrictions(RestrictionObjGuid, objects: null));
}
// ── CellPhysics.RestrictionObj plumbing (ordinary-cell no-op proof) ────
[Fact]
public void CellPhysics_DefaultRestrictionObj_IsZero()
{
// Default-constructed CellPhysics (the shape every existing test
// fixture builds) carries RestrictionObj == 0 — the exact "ordinary
// cell" no-op input CheckEntryRestrictions expects. Proves the new
// field cannot silently flip any existing fixture's behavior.
var cellPhysics = new CellPhysics
{
WorldTransform = System.Numerics.Matrix4x4.Identity,
InverseWorldTransform = System.Numerics.Matrix4x4.Identity,
Resolved = new System.Collections.Generic.Dictionary(),
};
Assert.Equal(0u, cellPhysics.RestrictionObj);
}
// ── End-to-end: the gate wired into Transition.FindEnvCollisions ──────
// A single indoor cell with an EMPTY physics BSP (no walls of its own) —
// the ONLY thing that can stop the sphere here is the entry-restriction
// gate. Proves the wiring at the top of FindEnvCollisions's indoor
// branch, not just the pure CheckEntryRestrictions logic above.
private const uint CellId = 0xA9B40157u;
private static CellPhysics MakeIndoorCell(uint restrictionObj) => new()
{
BSP = new PhysicsBSPTree
{
Root = new PhysicsBSPNode
{
Type = BSPNodeType.Leaf,
BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 10f },
},
},
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Resolved = new Dictionary(),
CellBSP = new CellBSPTree { Root = new CellBSPNode { Type = BSPNodeType.Leaf } },
RestrictionObj = restrictionObj,
};
private static PhysicsEngine MakeEngine(CellPhysics cellPhysics)
{
var engine = new PhysicsEngine();
engine.DataCache = new PhysicsDataCache();
engine.DataCache.RegisterCellStructForTest(CellId, cellPhysics);
return engine;
}
[Fact]
public void EndToEnd_RestrictedCell_PlayerCannotBypass_TransitionHaltsAtOrigin()
{
var engine = MakeEngine(MakeIndoorCell(restrictionObj: 0xABCDu));
var from = new Vector3(0.1f, 0f, 0.2f);
var to = new Vector3(0.7f, 0f, 0.2f);
var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, cellId: CellId);
t.ObjectInfo.State |= ObjectInfoState.IsPlayer;
t.FindTransitionalPosition(engine);
Assert.True(
System.MathF.Abs(t.SpherePath.CurPos.X - from.X) < 1e-4f,
$"Restricted cell must halt the player at the origin; CurPos.X={t.SpherePath.CurPos.X:F4}");
}
[Fact]
public void EndToEnd_RestrictedCell_PlayerCanBypass_TransitionReachesTarget()
{
var engine = MakeEngine(MakeIndoorCell(restrictionObj: 0xABCDu));
var from = new Vector3(0.1f, 0f, 0.2f);
var to = new Vector3(0.7f, 0f, 0.2f);
var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, cellId: CellId);
t.ObjectInfo.State |= ObjectInfoState.IsPlayer | ObjectInfoState.CanBypassMoveRestrictions;
t.FindTransitionalPosition(engine);
Assert.True(
System.MathF.Abs(t.SpherePath.CurPos.X - to.X) < 1e-4f,
$"An admin/bypass-flagged player must pass through unimpeded; CurPos.X={t.SpherePath.CurPos.X:F4}");
}
[Fact]
public void EndToEnd_OrdinaryCell_NoRestrictionObj_PlayerUnaffected()
{
// Zero-behavior-change proof: an ordinary (non-house) cell, empty BSP,
// reaches the target exactly like it did before AP-71 landed.
var engine = MakeEngine(MakeIndoorCell(restrictionObj: 0u));
var from = new Vector3(0.1f, 0f, 0.2f);
var to = new Vector3(0.7f, 0f, 0.2f);
var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, cellId: CellId);
t.ObjectInfo.State |= ObjectInfoState.IsPlayer;
t.FindTransitionalPosition(engine);
Assert.True(
System.MathF.Abs(t.SpherePath.CurPos.X - to.X) < 1e-4f,
$"Ordinary cell must be a complete no-op; CurPos.X={t.SpherePath.CurPos.X:F4}");
}
// ── AP-129 review-fix end-to-end: the full PhysicsEngine.Objects wiring
// (production-representative — a real house owner/guest resolution, not
// just an unresolved fail-closed default) ──────────────────────────────
[Fact]
public void EndToEnd_RestrictedCell_MoverIsResolvedOwner_TransitionReachesTarget()
{
var engine = MakeEngine(MakeIndoorCell(restrictionObj: RestrictionObjGuid));
engine.Objects = MakeObjectsWithRestrictionObject(
houseOwnerId: MoverGuid,
restrictions: new HouseRestrictionRecord(
OpenToPublic: false,
AllegianceMonarchId: 0,
Guests: new Dictionary()));
var from = new Vector3(0.1f, 0f, 0.2f);
var to = new Vector3(0.7f, 0f, 0.2f);
var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, cellId: CellId);
t.ObjectInfo.State |= ObjectInfoState.IsPlayer;
t.ObjectInfo.SelfEntityId = MoverGuid;
t.FindTransitionalPosition(engine);
Assert.True(
System.MathF.Abs(t.SpherePath.CurPos.X - to.X) < 1e-4f,
$"The house's own owner must pass through unimpeded; CurPos.X={t.SpherePath.CurPos.X:F4}");
}
[Fact]
public void EndToEnd_RestrictedCell_MoverIsResolvedNonGuest_TransitionHaltsAtOrigin()
{
var engine = MakeEngine(MakeIndoorCell(restrictionObj: RestrictionObjGuid));
engine.Objects = MakeObjectsWithRestrictionObject(
houseOwnerId: 0x50000099u,
restrictions: new HouseRestrictionRecord(
OpenToPublic: false,
AllegianceMonarchId: 0,
Guests: new Dictionary { [0x50000002u] = 0u }));
var from = new Vector3(0.1f, 0f, 0.2f);
var to = new Vector3(0.7f, 0f, 0.2f);
var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, cellId: CellId);
t.ObjectInfo.State |= ObjectInfoState.IsPlayer;
t.ObjectInfo.SelfEntityId = MoverGuid;
t.FindTransitionalPosition(engine);
Assert.True(
System.MathF.Abs(t.SpherePath.CurPos.X - from.X) < 1e-4f,
$"A resolved house that excludes this mover must still halt them; CurPos.X={t.SpherePath.CurPos.X:F4}");
}
}