Campaign P Slice P4 Opus review verdict: FIX-FIRST. RestrictionObjPrevalenceInspectionTests
(commit 3b5e0992) found 103,766 of 729,888 installed EnvCells (1,293 landblocks -
the whole housing estate) carry a baked RestrictionObj. The AP-71 gate's
unconditional fail-closed default (CanMoveInto unmodeled) would have locked
every apartment/cottage/villa interior for every player, including its own
owner - a live regression, not the "inert in dev content" the original
register row assumed.
Ports ACCWeenieObject::CanMoveInto (0x0058da40, pc:407982-408056) and
RestrictionDB::IsAllowedIn (0x005ae8f0, pc:444493-444516) verbatim into
ObjectInfo.CheckEntryRestrictions:
- owner_iid == 0 or == mover's own guid -> admit (open/owner)
- no RestrictionDB (retail _db == 0, i.e. never authored or not yet
received) -> admit
- present RestrictionDB -> IsAllowedIn: open-to-public flag, OR mover
shares the house's allegiance monarch, OR mover's own guid is a
guest-table member
- unresolved restriction object -> fails CLOSED, exactly retail's own
fallback when GetObjectA can't resolve it (pc:704-716)
Wire feed (Core.Net):
- CreateObject.cs: HouseOwner (WeenieHeaderFlag 0x02000000), HouseRestrictions
(0x04000000), and Monarch (0x40) PWD-tail fields were parsed-and-skipped;
now captured. Also fixes the HouseRestrictions PHashTable header
misconception: the wire is ONE packed u32 (low 24 bits = entry count),
not a separate count(u16)+numBuckets(u16) pair - verified against
Chorizite's RestrictionDB.generated.cs. The old skip's byte-count
happened to match for realistic guest-list sizes, but a future
numBuckets value >255 would have corrupted the parse; now correct
regardless.
- GameEvents.cs/GameEventWiring.cs: new House_UpdateRestrictions (0x0248)
parser + wiring - retail's live guest-list refresh, whole-unit replace.
No-ops if the house object hasn't arrived via CreateObject yet.
- ClientObject/WeenieData/ClientObjectTable: HouseOwnerId, MonarchId,
Restrictions (new HouseRestrictionRecord) fields + merge-preserving
Ingest + targeted UpdateHouseRestrictions.
Physics wiring:
- PhysicsEngine gains an Objects (ClientObjectTable?) property, mirroring
the existing DataCache pattern - acdream's GetObjectA equivalent, used
ONLY by the entry-restriction gate.
- RuntimeEntityObjectLifetime wires Physics.Engine.Objects = Objects in
all three constructors, right alongside the table's own construction -
the same canonical table every other subsystem borrows from, never a
second one. This is the production fix: without it the gate still fails
closed on every restricted cell (unresolvable object), so the wiring is
load-bearing, not cosmetic.
Register: AP-129 narrowed (not retired) to the genuine remaining residual -
House_UpdateRestrictions' Sequence byte isn't used for staleness/reordering
rejection (low-probability, self-correcting), and outdoor CLandCell
restriction (a separate DAT structure) remains unported and unaffected by
this fix.
Tests: 15 new/updated in Ap71EntryRestrictionGateTests.cs (resolved-unowned
admits, owner admits, present-list-excluded blocks, present-list-included
admits, open-to-public admits, shared-allegiance-monarch admits, unresolved
blocks via null and via an empty table, plus two new end-to-end
PhysicsEngine.Objects-wired scenarios); 2 new CreateObject parser tests +
2 new GameEventWiring tests for the wire feed.
AcDream.Core.Tests: 4049 passed, 2 skipped, 0 failed.
AcDream.Core.Net.Tests: 761 passed, 0 skipped, 0 failed.
Complete solution suite: 9,961 total, 9,956 passed, 5 skipped, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
473 lines
20 KiB
C#
473 lines
20 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Conformance tests for AP-71/AP-129 (Campaign P Slice P4, 2026-07-30 —
|
|
/// including the 2026-07-30 Opus review fix) — retail
|
|
/// <c>CObjCell::check_entry_restrictions</c> (named-retail pc:308873-308912,
|
|
/// 0x0052b6d0) plus <c>ACCWeenieObject::CanMoveInto</c> (pc:407982-408056)
|
|
/// and <c>RestrictionDB::IsAllowedIn</c> (pc:444493-444516), ported as
|
|
/// <see cref="ObjectInfo.CheckEntryRestrictions"/> and wired at the head of
|
|
/// the indoor branch of <c>Transition.FindEnvCollisions</c>
|
|
/// (<c>src/AcDream.Core/Physics/TransitionTypes.cs</c>).
|
|
///
|
|
/// <para>
|
|
/// Retail gate order: NPCs/props (not a player) bypass entirely; a mover
|
|
/// whose PWD bitfield grants <c>CanBypassMoveRestrictions</c> (BF_ADMIN &
|
|
/// BF_IMMUNE_CELL_RESTRICTIONS) bypasses; an ordinary cell (no
|
|
/// <c>restriction_obj</c> authored) is a no-op; otherwise retail resolves
|
|
/// the restriction weenie (<see cref="ClientObjectTable"/> is acdream's
|
|
/// <c>GetObjectA</c> equivalent) and asks <c>CanMoveInto</c>: 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 (<c>_db == 0</c>) admits; otherwise
|
|
/// <c>RestrictionDB::IsAllowedIn</c> decides (open-to-public, shared
|
|
/// allegiance monarch, or explicit guest-table membership).
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// AP-129 review-fix context: 103,766 of 729,888 installed EnvCells (1,293
|
|
/// landblocks — the housing estate) carry a baked <c>RestrictionObj</c>
|
|
/// (<c>RestrictionObjPrevalenceInspectionTests</c>). 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.
|
|
/// </para>
|
|
/// </summary>
|
|
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<uint, uint>());
|
|
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<uint, uint> { [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<uint, uint> { [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<uint, uint>());
|
|
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<uint, uint>());
|
|
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<ushort, ResolvedPolygon>(),
|
|
};
|
|
|
|
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<ushort, ResolvedPolygon>(),
|
|
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<uint, uint>()));
|
|
|
|
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<uint, uint> { [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}");
|
|
}
|
|
}
|