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 explicitAccessGrant) — and refuses unconditionally, owner included, whileIsSealed. 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 flags, superadmin), replaced withPermissionSetthe 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/DoorComponentmodel 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.