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}"); } }