Skip to content

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:

  1. Load or create the buyer's Economy balance.
  2. Load or create the group's Ownership (keyed by GroupOwnableId) — refuse if already owned.
  3. Refuse if the buyer can't afford GroupPrice.
  4. Publish HousingGroupPurchasing (cancellable — the same veto extension point DoorPurchasing already gives World).
  5. Assign ownership of the group. IOwnershipService.AssignOwnerAsync — unchanged, no new parameter, no group concept added to it.
  6. Link every named member. For each id in MemberOwnableIds, try it first as a registered Door (IPropertyService.Find), then as a registered WorldContainer (IWorldContainerService.Find), and call the matching SetGroupAsync to point its GroupId at 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.
  7. 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.
  8. 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 (and WorldContainerStore.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, individual RequestPurchaseDoor, not silently allow double-selling it. AssignOwnerAsync itself stays keyed at door.Id unchanged: a standalone door being bought individually never has GroupId set, so door.GroupId ?? door.Id == door.Id there anyway, and a grouped door never reaches AssignOwnerAsync at 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.HasAccess still never resolves OwnerKind.Team/ .Gang membership — the same pre-existing gap Code/Ownerships/README.md already 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).