Housing: Property Groups¶
ROADMAP.md Milestone 14's Housing build item, resolving the "master/slave door linking → property groups" deferral Milestone 7 and 20_WORLD_LOCKS_AND_CONTAINERS.md §2 both already named. The architecture decision itself is ADR-0013 (Accepted) — this document explains the resulting shape and traces a purchase end to end; ADR-0013's own Decision section is the exact spec, not repeated here in full.
1. Composition, not a tag layered on top¶
A property group is its own Ownership record, keyed by a fresh OwnableId
(HousingListingDefinition.GroupOwnableId) — exactly like a Door or WorldContainer gets one
for itself. Door and WorldContainer each gain their own, independently-added, nullable
GroupId : OwnableId? field — not a shared base class, not a generic "grouping" concept on
Ownership itself. See ADR-0013's Decision point 1 for the full rejection of the tag/association
alternative, and its rejection of putting GroupId on generic Ownership (the same split
ADR-0009 already drew for VehicleOwnership).
The consequence that actually matters: a group member's own Ownership record (keyed by its
own id) is simply never populated once it's part of a group. There is nothing to keep in sync
— no second copy of the owner, no second copy of the access list. Every check defers to the
group's single record instead.
2. Buying a group: HousingStore.PurchaseGroupAsync¶
HousingListingDefinition is a GameResource, ADR-0004's precedent (ItemDefinition,
JobDefinition): content, not code. It names a fixed DisplayName, its own fixed
GroupOwnableId, the fixed set of member OwnableIds it sells (MemberOwnableIds — already-
placed Door/WorldContainer instances' own ids), and GroupPrice.
PurchaseGroupAsync replicates PropertyStore.PurchaseAsync's own shape, one level up:
- Load or create the buyer's
Economybalance. - Load or create the group's
Ownership(keyed byGroupOwnableId) — refuse if already owned. - Refuse if the buyer can't afford
GroupPrice. - Publish
HousingGroupPurchasing(cancellable — the same veto extension pointDoorPurchasingalready gives World). - Assign ownership of the group.
IOwnershipService.AssignOwnerAsync— unchanged, no new parameter, no group concept added to it. - Link every named member. For each id in
MemberOwnableIds, try it first as a registeredDoor(IPropertyService.Find), then as a registeredWorldContainer(IWorldContainerService.Find), and call the matchingSetGroupAsyncto point itsGroupIdat the group. A member that resolves to neither is a logged, operator-visible anomaly, not a reason to fail the purchase this late — the group is genuinely bought regardless. - Debit the price. Same ordering rationale as
PropertyStore.PurchaseAsync's own header comment: ownership-before-debit is a safe abort if step 5 fails, and a rare, logged anomaly (never "money gone, nothing owned") if only the debit fails afterward. - Publish
HousingGroupPurchased.
The same per-entity concurrency guard PropertyStore._purchasesInFlight uses reappears here as
HousingStore._purchasesInFlight, keyed by the group's OwnableId instead of a door's — see
HousingListingDefinition.cs's header comment for why that id is knowable synchronously, before
any await, the same way door.Id already is.
No persisted state of its own. HousingStore never opens a new persistence collection.
The group's Ownership lives under ownership/{groupId} (Ownership module — no different from
any other OwnableId); each member's GroupId link lives on that member's own
doors/{id}/containers/{id} document (World module, via the new SetGroupAsync write). This
is ADR-0013 point 3's promise — IOwnershipService gains zero new surface — carried one step
further: Housing itself introduces no group-membership document either.
3. Every existing ownership-by-door.Id call site now resolves door.GroupId ?? door.Id¶
ADR-0013's own Consequences → Negative section names this as the one real cost: "any existing
call site that loads a door's Ownership directly by door.Id" has to change. Two real call
sites needed it:
PropertyStore.SetLockedAsync(andWorldContainerStore.SetLockedAsync) — the ADR's own named example. A locked/unlocked check against a group member must consult the group's access list, not an always-unowned per-door record.PropertyStore.PurchaseAsync's already-owned check — a door named as a member of a purchased group must refuse a second, individualRequestPurchaseDoor, not silently allow double-selling it.AssignOwnerAsyncitself stays keyed atdoor.Idunchanged: a standalone door being bought individually never hasGroupIdset, sodoor.GroupId ?? door.Id == door.Idthere anyway, and a grouped door never reachesAssignOwnerAsyncat all — the already-owned check (now resolved through the group) refuses it first.
DoorComponent's own IsOwned/OwnerName sync-state seed (RegisterAsync) resolves the same
way, so a placed door's client-visible "owned" state is correct immediately for a grouped door
too, not only for a standalone one.
4. Access propagation: one call, not a loop¶
Granting a friend access to "the house" is exactly one call:
IOwnershipService.GrantAccessAsync(groupId, friendId, AccessLevel.Use). There is no second
copy of the grant to propagate to member doors, because member doors never carry their own —
every subsequent SetLockedAsync against any member resolves ownership through the group and
finds the same grant. UnitTests/Housing/HousingStoreTests.cs's
GrantingAccessToTheGroupGrantsAccessToEveryMemberNamedInTheListing proves this end to end: buy
a two-door group, grant once, then lock/unlock both doors as the grantee.
5. Compatibility: a standalone door is unaffected¶
DoorDocument/WorldContainerDocument both move to schema version 2 for the new GroupId
field. Both migrations (DoorDocumentV1ToV2Migration, WorldContainerDocumentV1ToV2Migration)
are identity migrations — the field is additive with a harmless null default ("never part of
a group"), the same shape CharacterDocumentV1ToV2Migration/CharacterDocumentV2ToV3Migration
(Code/Characters/CharacterStore.cs) already established. A door saved before this milestone
loads with GroupId = null, and every existing lock/access check resolves to exactly
door.Id — unaffected. UnitTests/World/PropertyStoreTests.cs's
DoorDocumentSchemaVersion1MigratesWithGroupIdDefaultingToNullAndExistingChecksBehaveAsBefore
(and its WorldContainerStoreTests.cs mirror) is the regression test.
6. Not built this pass¶
- Reselling a group as a unit, and what happens to member access grants on resale — ADR-0013's own deliberately deferred open question, the same way ADR-0009 deferred vehicle re-key/impound.
- Changing a group's membership after purchase. Fixed at purchase time from the listing (ADR-0013 point 2); there is no "add/remove a member" API.
- Team/Gang-owned groups.
IOwnershipService.HasAccessstill never resolvesOwnerKind.Team/.Gangmembership — the same pre-existing gapCode/Ownerships/README.mdalready names, unrelated to and unchanged by this milestone.
7. Testing¶
HousingStore's purchase flow is tested against real OwnershipStore/EconomyStore/
PropertyStore/WorldContainerStore instances (each already covered by its own suite),
following PropertyStoreTests.cs's established precedent of a real collaborator over a fake:
ownership-before-debit ordering, the veto extension point, the concurrency guard, an
unregistered member logging an anomaly without failing the purchase, a mixed door-and-container
group, and both of ADR-0013's Compliance-section-named tests — access propagation
(§4 above) and the migration/back-compat guarantee (§5 above).