fix(physics): AP-129 review fix - port CanMoveInto/IsAllowedIn, stop failing closed

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>
This commit is contained in:
Erik 2026-07-30 11:36:11 +02:00
parent dc0468cc2b
commit 7a0f836af5
15 changed files with 704 additions and 81 deletions

View file

@ -27,6 +27,13 @@ public sealed class GameEventWiringTests
return result;
}
private static void AppendU32(List<byte> payload, uint value)
{
byte[] u32 = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(u32, value);
payload.AddRange(u32);
}
private static byte[] WrapEnvelope(GameEventType type, byte[] payload)
{
byte[] body = new byte[GameEventEnvelope.HeaderSize + payload.Length];
@ -222,6 +229,65 @@ public sealed class GameEventWiringTests
Assert.Equal(new[] { 0x1500u }, wieldConfirmed);
}
/// <summary>
/// AP-129 (Campaign P Slice P4 review fix, 2026-07-30): 0x0248
/// House_UpdateRestrictions refreshes an already-known house object's
/// guest/ban list. Wire shape verified against Chorizite
/// House_UpdateRestrictions.generated.cs: byte Sequence, uint SenderId,
/// then RestrictionDB (Version u32, Flags u32, MonarchId u32, PHashTable
/// packed-u32-count + count × (guid u32 + value u32)).
/// </summary>
[Fact]
public void WireAll_HouseUpdateRestrictions_UpdatesClientObjectTable()
{
var (d, items, _, _, _) = MakeAll();
const uint houseGuid = 0x70200099u;
items.AddOrUpdate(new ClientObject { ObjectId = houseGuid, WeenieClassId = 1 });
var payload = new List<byte>();
payload.Add(5); // Sequence
AppendU32(payload, houseGuid); // SenderId
AppendU32(payload, 0x10000002u); // Version
AppendU32(payload, 0u); // Flags (private)
AppendU32(payload, 0x50000500u); // MonarchId
AppendU32(payload, 1u); // PHashTable packed size (count=1)
AppendU32(payload, 0x50000001u); // guest guid
AppendU32(payload, 1u); // guest permission (storage)
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.HouseUpdateRestrictions, payload.ToArray()));
d.Dispatch(env!.Value);
var house = items.Get(houseGuid);
Assert.NotNull(house);
Assert.NotNull(house!.Restrictions);
Assert.False(house.Restrictions!.OpenToPublic);
Assert.Equal(0x50000500u, house.Restrictions.AllegianceMonarchId);
Assert.Single(house.Restrictions.Guests);
Assert.Equal(1u, house.Restrictions.Guests[0x50000001u]);
}
[Fact]
public void WireAll_HouseUpdateRestrictions_UnknownHouseObject_NoOps()
{
// Retail's own client would only receive this for an object it already
// knows about; a stub half-formed ClientObject must not be invented.
var (d, items, _, _, _) = MakeAll();
const uint unknownHouseGuid = 0x70200100u;
var payload = new List<byte>();
payload.Add(1);
AppendU32(payload, unknownHouseGuid);
AppendU32(payload, 0x10000002u);
AppendU32(payload, 1u); // open
AppendU32(payload, 0u);
AppendU32(payload, 0u); // zero guests
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.HouseUpdateRestrictions, payload.ToArray()));
d.Dispatch(env!.Value);
Assert.Null(items.Get(unknownHouseGuid));
}
[Fact]
public void WireAll_ConfirmationRequest_UsesRetailTwoIntegerHeader()
{

View file

@ -431,15 +431,22 @@ public sealed class CreateObjectTests
}
[Fact]
public void TryParse_HouseRestrictionsSkipped_ThenIconOverlayCaptured()
public void TryParse_HouseRestrictionsCaptured_ThenIconOverlayCaptured()
{
// Verifies that the variable-length RestrictionDB skip (weenieFlags bit
// 0x04000000) lands the cursor at the correct position so that
// IconOverlay (bit 0x40000000) immediately after it is still captured.
// AP-129 (Campaign P Slice P4 review fix, 2026-07-30): HouseRestrictions
// (weenieFlags bit 0x04000000) is now CAPTURED, not skipped. Verifies both
// that the RestrictionDB parses correctly (zero-entry PHashTable) AND that
// the cursor lands at the correct position so IconOverlay (bit 0x40000000)
// immediately after it is still captured.
//
// Wire layout per ACE RestrictionDB (16 bytes, zero entries):
// Version(u32) + OpenStatus(u32) + MonarchId(u32) = 12 bytes
// count(u16) + numBuckets(u16) = 4 bytes
// Wire layout per Chorizite RestrictionDB.generated.cs (verified against
// protocol.xml:6270-6275; zero entries):
// Version(u32) + Flags(u32) + MonarchId(u32) = 12 bytes
// PHashTable header: ONE packed u32 whose low 24 bits are the entry
// count (NOT a separate count(u16)+numBuckets(u16) pair — the fixture
// below writes count=0/numBuckets=768 as two u16s, which happens to
// decode identically under the packed-u32 mask since 768 (0x0300)
// lands entirely in the discarded high byte) = 4 bytes
// entries: count(0) × 8 = 0 bytes
// total = 16 bytes
//
@ -463,10 +470,65 @@ public sealed class CreateObjectTests
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x06003333u, parsed!.Value.IconOverlayId);
Assert.NotNull(parsed!.Value.Restrictions);
Assert.False(parsed.Value.Restrictions!.OpenToPublic);
Assert.Equal(0u, parsed.Value.Restrictions.AllegianceMonarchId);
Assert.Empty(parsed.Value.Restrictions.Guests);
Assert.Equal(0x06003333u, parsed.Value.IconOverlayId);
Assert.Equal(0x06004444u, parsed.Value.IconUnderlayId);
}
[Fact]
public void TryParse_HouseRestrictionsWithGuests_CapturesGuestTable()
{
// AP-129: a non-empty guest table (2 entries) parses correctly and the
// cursor still reaches HookItemTypes/Monarch/HookType/IconOverlay after it.
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x5000000Fu,
name: "HouseCottage",
itemType: (uint)ItemType.Portal,
weenieFlags: 0x04000000u | 0x20000000u | 0x00000040u | 0x10000000u | 0x40000000u,
houseRestrictionOpen: false,
houseRestrictionMonarchId: 0x50000500u,
houseRestrictionGuests: new Dictionary<uint, uint> { [0x50000001u] = 0u, [0x50000002u] = 1u },
hookItemTypes: 0x1u,
hookType: 2,
iconOverlayId: 0x5555u);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.NotNull(parsed!.Value.Restrictions);
Assert.False(parsed.Value.Restrictions!.OpenToPublic);
Assert.Equal(0x50000500u, parsed.Value.Restrictions.AllegianceMonarchId);
Assert.Equal(2, parsed.Value.Restrictions.Guests.Count);
Assert.Equal(0u, parsed.Value.Restrictions.Guests[0x50000001u]);
Assert.Equal(1u, parsed.Value.Restrictions.Guests[0x50000002u]);
Assert.Equal(0x1u, parsed.Value.HookItemTypes);
Assert.Equal((uint)2, parsed.Value.HookType);
Assert.Equal(0x06005555u, parsed.Value.IconOverlayId);
}
[Fact]
public void TryParse_HouseOwnerAndMonarch_Captured()
{
// AP-129: HouseOwner (0x02000000) and Monarch (0x00000040) are now
// captured instead of skipped.
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000012u,
name: "HouseDoor",
itemType: (uint)ItemType.Portal,
weenieFlags: 0x02000000u | 0x00000040u,
houseOwnerId: 0x50000042u,
monarchId: 0x50000777u);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x50000042u, parsed!.Value.HouseOwnerId);
Assert.Equal(0x50000777u, parsed.Value.MonarchId);
}
// -----------------------------------------------------------------------
// D.5.2 (2026-06-17): UiEffects bitfield (weenieFlags bit 0x80) — captured
// instead of skipped. Drives the icon's effect-overlay recolor.
@ -768,7 +830,13 @@ public sealed class CreateObjectTests
uint petOwnerId = 0,
ushort spellId = 0,
uint hookItemTypes = 0,
ushort hookType = 0)
ushort hookType = 0,
// AP-129 (Campaign P Slice P4 review fix, 2026-07-30).
uint? houseOwnerId = null,
uint? monarchId = null,
bool houseRestrictionOpen = false,
uint houseRestrictionMonarchId = 0,
IReadOnlyDictionary<uint, uint>? houseRestrictionGuests = null)
{
var bytes = new List<byte>();
WriteU32(bytes, CreateObject.Opcode);
@ -858,20 +926,25 @@ public sealed class CreateObjectTests
}
if ((weenieFlags & 0x00200000u) != 0) WriteU16(bytes, burden ?? 0); // Burden u16
if ((weenieFlags & 0x00400000u) != 0) WriteU16(bytes, spellId); // Spell u16
if ((weenieFlags & 0x02000000u) != 0) WriteU32(bytes, 0); // HouseOwner u32
// HouseRestrictions (0x04000000): not parameterized (zero entries).
// Wire: Version(u32) + OpenStatus(u32) + MonarchId(u32) + count(u16) + numBuckets(u16) + entries.
// Zero entries → 16 bytes total.
if ((weenieFlags & 0x02000000u) != 0) WriteU32(bytes, houseOwnerId ?? 0); // HouseOwner u32
// HouseRestrictions (0x04000000). Wire per Chorizite RestrictionDB.generated.cs:
// Version(u32) + Flags(u32) + MonarchId(u32) + PHashTable<uint,uint> (one packed
// u32 whose low 24 bits are the entry count, then count × (guid u32 + value u32)).
if ((weenieFlags & 0x04000000u) != 0)
{
WriteU32(bytes, 0x10000002u); // Version
WriteU32(bytes, 0u); // OpenStatus
WriteU32(bytes, 0u); // MonarchId
WriteU16(bytes, 0); // count
WriteU16(bytes, 768); // numBuckets (retail constant)
WriteU32(bytes, houseRestrictionOpen ? 1u : 0u); // Flags
WriteU32(bytes, houseRestrictionMonarchId); // MonarchId
var guests = houseRestrictionGuests ?? new Dictionary<uint, uint>();
WriteU32(bytes, (uint)guests.Count); // PHashTable packed size (low 24 bits = count)
foreach (var kvp in guests)
{
WriteU32(bytes, kvp.Key);
WriteU32(bytes, kvp.Value);
}
}
if ((weenieFlags & 0x20000000u) != 0) WriteU32(bytes, hookItemTypes); // HookItemTypes u32
if ((weenieFlags & 0x00000040u) != 0) WriteU32(bytes, 0); // Monarch u32
if ((weenieFlags & 0x00000040u) != 0) WriteU32(bytes, monarchId ?? 0); // Monarch u32
if ((weenieFlags & 0x10000000u) != 0) WriteU16(bytes, hookType); // HookType u16
if ((weenieFlags & 0x40000000u) != 0) WritePackedDword(bytes, iconOverlayId); // IconOverlay
if ((weenieFlags2 & 0x00000001u) != 0) WritePackedDword(bytes, iconUnderlayId); // IconUnderlay

View file

@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Items;
using AcDream.Core.Physics;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
@ -8,34 +9,49 @@ using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Conformance tests for AP-71 (Campaign P Slice P4, 2026-07-30) — retail
/// 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), 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>).
/// 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 &amp;
/// BF_IMMUNE_CELL_RESTRICTIONS) bypasses; an ordinary cell (no
/// <c>restriction_obj</c> authored) is a no-op; otherwise retail resolves
/// the restriction weenie and asks <c>CanMoveInto</c> — acdream has no
/// owner/guest-list model for that yet, so it fails CLOSED (Collided),
/// matching retail's own fallback when the restriction object can't be
/// resolved (pc:704-716).
/// 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));
Assert.Equal(TransitionState.OK, mover.CheckEntryRestrictions(cellRestrictionObj: 0, objects: null));
}
[Fact]
@ -45,7 +61,7 @@ public sealed class Ap71EntryRestrictionGateTests
// 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));
Assert.Equal(TransitionState.OK, mover.CheckEntryRestrictions(cellRestrictionObj: 0, objects: null));
}
[Fact]
@ -57,36 +73,192 @@ public sealed class Ap71EntryRestrictionGateTests
Assert.Equal(
TransitionState.OK,
mover.CheckEntryRestrictions(RestrictionObjGuid));
mover.CheckEntryRestrictions(RestrictionObjGuid, objects: null));
}
[Fact]
public void RestrictedCell_PlayerMover_CannotBypass_FailsClosed()
public void RestrictedCell_PlayerMover_UnresolvedRestrictionObject_FailsClosed()
{
// A restricted cell, a player mover, no CanBypassMoveRestrictions bit:
// acdream cannot resolve CanMoveInto (no guest-list model) -> Collided,
// matching retail's own fallback when the restriction weenie can't be
// resolved (pc:704-716 fallthrough to COLLIDED_TS).
var mover = new ObjectInfo { State = ObjectInfoState.IsPlayer };
// 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));
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.
// 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));
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
@ -135,11 +307,11 @@ public sealed class Ap71EntryRestrictionGateTests
ObjectInfoState moverState =
ObjectInfoState.IsPlayer | EntityCollisionFlagsExt.FromPwdBitfield(bitfield).ToMoverState();
var mover = new ObjectInfo { State = moverState };
var mover = new ObjectInfo { State = moverState, SelfEntityId = MoverGuid };
Assert.Equal(
TransitionState.OK,
mover.CheckEntryRestrictions(RestrictionObjGuid));
mover.CheckEntryRestrictions(RestrictionObjGuid, objects: null));
}
// ── CellPhysics.RestrictionObj plumbing (ordinary-cell no-op proof) ────
@ -246,4 +418,56 @@ public sealed class Ap71EntryRestrictionGateTests
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}");
}
}