Skip to content

World: Locks, Seals, and a Second Container Type

The last named system in ROADMAP.md Milestone 7: "the rest of World (containers beyond the first type, locks, seals)." See Legacy/04_ENTITIES_DOORS_PROPERTY.md for the full legacy trace.


1. Locks: two independent facts, one computed meaning

Legacy packs door state into an untyped bitfield (OBJ_LOCKED, OBJ_SEALED, ...) and duplicates OBJ_LOCKED as a separate _Locked field that must be kept in sync by hand — D-16: "two sources of truth for one fact."

Door gains IsLocked and IsSealed as two independent, narrowly-scoped booleans, and a computed IsEffectivelyLocked => IsLocked || IsSealed for whichever future system actually opens a door. Sealing does not set IsLocked = true — that would immediately reintroduce the exact defect being fixed, just with fewer bits. The combined meaning is computed, once, at the point something needs it — the same discipline already used for NeedDefinition.DecayPerSecond and JobDefinition.AssetId.

IPropertyService.SetLockedAsync and SetSealedAsync are gated differently, matching legacy exactly:

  • Locking is an owner power: gated by IOwnershipService.HasAccess (the door's owner, or anyone holding an explicit AccessGrant) — and refuses unconditionally, owner included, while IsSealed. Legacy: "Sealed — permanently locked, cannot be opened at all."
  • Sealing is an admin power, independent of ownership entirely: gated by the new "world.seal" permission (legacy's access flag s, superadmin), replaced with PermissionSet the same way every other single-character access flag was this milestone.

Both are no-op successes if the requested state already holds, mirroring JoinJobAsync's already-there no-op. Neither needs PurchaseAsync's _purchasesInFlight concurrency guard — flipping an idempotent boolean has no scarce resource two concurrent callers could double-allocate.

2. Not built: everything that's a bigger scope, not a blocked gap

Unlike most of this milestone's deferred items, none of these are blocked by a missing platform capability — they're simply more scope than this pass, named here rather than built partially:

  • Master/slave door linking (§2.4) — a real requirement ("every map has double doors"), but a genuine property-group concept, not a small extension of what exists.
  • Padlocks as physical entities (§2.5), breach/jam timing and autoclose (§2.3) — need a placeable, attackable world entity and a real open/closed door state respectively; Door/DoorComponent model ownership and lock state, not physical door animation.
  • Door tax and 50% resale (§3) — needs Economy's payday loop, which doesn't exist yet.

3. Why /seal isn't a chat command

Legacy's admin tools resolve "which door" via entity trace/ESP — the player looks at a door and the console command acts on whatever they're looking at. That needs a Transform/raycast binding, which doesn't exist anywhere in this codebase (the same gap named repeatedly this milestone, most recently in 19_CHAT_FRAMEWORK.md §1).

The fix is the one RequestPurchaseDoor already established, reused rather than reinvented: RequestLockDoor/RequestSealDoor are RPCs on the specific DoorComponent instance the client already holds a reference to — a component method, not a global command needing a name or a target to resolve. Buying a door never needed "which door" resolution for the same reason; locking and sealing don't either.

4. A second ownable, lockable, inventory-holding entity type

Code/Inventories/WorldContainerComponent.cs is explicitly "the first world container type" — deliberately unowned and always-accessible, a communal crate. Legacy's own framing is that ownership is generic: "doors, vehicles and containers are the common cases, but the mechanism is generic." WorldContainer/IWorldContainerService/WorldContainerStore prove that directly — the same shape as Door/IPropertyService/PropertyStore minus a purchase price (containers are never bought in legacy; there is no "Container Cost" setting), with the identical HasAccess-gated lock and "world.seal"-gated seal.

Deliberately not built by extending Door or IPropertyService: bolting a container onto the purchase-flow service would mean either a fake PurchasePrice nothing charges, or an awkward nullable-purchase branch on a method that exists specifically to charge money. A new, small, parallel service is more honest about what's actually different.

LockableContainerComponent is a second IInventoryHolder (alongside WorldContainerComponent), following its exact OnAwake/fire-and-forget-load shape, plus DoorComponent's Lock/Seal RPCs. Three separate concerns, three separate documents, one id:

Concern Owned by Document
Who owns it, who else has access Ownership ownership/{id}
Locked / sealed World containers/{id}
What's inside Inventory inventories/{id}

WorldModule.DependsOn gains InventoryModule for this — checked not circular (InventoryModule.DependsOn does not depend on WorldModule).

5. The one real gap this creates: no way to ever own a container

Doors are ownable via PurchaseAsync. Containers have no purchase flow (legacy never sells them), and there is no /setowner-equivalent — it would need target-resolution-by-name for both the entity and the recipient, the same blocked gap behind Crime's /warrant (17_CRIME_FRAMEWORK.md §4) and Chat's /pm (19_CHAT_FRAMEWORK.md §1). A container's Ownership claim therefore starts, and stays, unowned this pass. Confirmed: OwnershipStore.HasAccess returns false for an unowned entity's claim, no exceptions — so RequestLockContainer is real and fully tested at the service layer, and currently unreachable by any player until an ownership-assignment flow exists. Named explicitly here and in Code/World/README.md, the same way Crime shipped a fully real, fully tested ICrimeService with no reachable command entry point.

6. Testing

PropertyStore's and WorldContainerStore's new methods are tested against a real OwnershipStore (already covered by its own suite), following PropertyStoreTests.cs's established precedent of a real collaborator over a fake: the access gate, the "world.seal" gate, sealed-blocks-lock-and-unlock (owner included), no-op-when-already-set, and that both flags persist across a fresh store instance sharing the same backing persistence.