From a2a4d48de52ff17f5624fb2836b7ea250560fbd3 Mon Sep 17 00:00:00 2001 From: Zeeshaun Date: Sun, 15 Mar 2026 13:57:15 -0500 Subject: [PATCH] Inventory enhancements --- .../Controllers/InventoryController.cs | 63 ++++++- microservices/InventoryApi/DOCUMENTS.md | 57 +++++- .../Models/CreateItemDefinitionRequest.cs | 16 ++ .../InventoryApi/Models/ItemDefinition.cs | 33 ++++ .../Models/ItemDefinitionResponse.cs | 29 +++ .../Models/UpdateItemDefinitionRequest.cs | 14 ++ microservices/InventoryApi/README.md | 8 + .../InventoryApi/Services/InventoryStore.cs | 176 +++++++++++++----- 8 files changed, 351 insertions(+), 45 deletions(-) create mode 100644 microservices/InventoryApi/Models/CreateItemDefinitionRequest.cs create mode 100644 microservices/InventoryApi/Models/ItemDefinition.cs create mode 100644 microservices/InventoryApi/Models/ItemDefinitionResponse.cs create mode 100644 microservices/InventoryApi/Models/UpdateItemDefinitionRequest.cs diff --git a/microservices/InventoryApi/Controllers/InventoryController.cs b/microservices/InventoryApi/Controllers/InventoryController.cs index 85c3c0b..7dd51c9 100644 --- a/microservices/InventoryApi/Controllers/InventoryController.cs +++ b/microservices/InventoryApi/Controllers/InventoryController.cs @@ -17,6 +17,63 @@ public class InventoryController : ControllerBase _inventory = inventory; } + [HttpGet("item-definitions")] + [Authorize(Roles = "USER,SUPER")] + public async Task ListItemDefinitions() + { + var definitions = await _inventory.ListItemDefinitionsAsync(); + return Ok(definitions.Select(ItemDefinitionResponse.FromModel).ToList()); + } + + [HttpGet("item-definitions/{itemKey}")] + [Authorize(Roles = "USER,SUPER")] + public async Task GetItemDefinition(string itemKey) + { + var definition = await _inventory.GetItemDefinitionAsync(itemKey); + if (definition is null) + return NotFound(); + + return Ok(ItemDefinitionResponse.FromModel(definition)); + } + + [HttpPost("item-definitions")] + [Authorize(Roles = "SUPER")] + public async Task CreateItemDefinition([FromBody] CreateItemDefinitionRequest req) + { + if (string.IsNullOrWhiteSpace(req.ItemKey)) + return BadRequest("itemKey required"); + if (string.IsNullOrWhiteSpace(req.DisplayName)) + return BadRequest("displayName required"); + if (req.MaxStackSize <= 0) + return BadRequest("maxStackSize must be greater than 0"); + if (!req.Stackable && req.MaxStackSize != 1) + return BadRequest("Non-stackable items must have maxStackSize of 1"); + + var created = await _inventory.CreateItemDefinitionAsync(req); + if (created is null) + return Conflict("Item definition already exists"); + + return Ok(ItemDefinitionResponse.FromModel(created)); + } + + [HttpPut("item-definitions/{itemKey}")] + [Authorize(Roles = "SUPER")] + public async Task UpdateItemDefinition(string itemKey, [FromBody] UpdateItemDefinitionRequest req) + { + if (string.IsNullOrWhiteSpace(req.DisplayName)) + return BadRequest("displayName required"); + if (req.MaxStackSize <= 0) + return BadRequest("maxStackSize must be greater than 0"); + if (!req.Stackable && req.MaxStackSize != 1) + return BadRequest("Non-stackable items must have maxStackSize of 1"); + + var updated = await _inventory.UpdateItemDefinitionAsync(itemKey, req); + if (updated is null) + return NotFound(); + + return Ok(ItemDefinitionResponse.FromModel(updated)); + } + [HttpGet("by-owner/{ownerType}/{ownerId}")] [Authorize(Roles = "USER,SUPER")] public async Task GetByOwner(string ownerType, string ownerId) @@ -63,7 +120,11 @@ public class InventoryController : ControllerBase if (!access.IsAuthorized) return Forbid(); - var items = await _inventory.GrantAsync(access, req); + var definition = await _inventory.GetItemDefinitionAsync(req.ItemKey); + if (definition is null) + return BadRequest("Unknown itemKey"); + + var items = await _inventory.GrantAsync(access, req, definition); return Ok(new InventoryOwnerResponse { OwnerType = access.OwnerType, diff --git a/microservices/InventoryApi/DOCUMENTS.md b/microservices/InventoryApi/DOCUMENTS.md index 88d0c86..05e00a5 100644 --- a/microservices/InventoryApi/DOCUMENTS.md +++ b/microservices/InventoryApi/DOCUMENTS.md @@ -3,6 +3,29 @@ This service stores one MongoDB document per inventory item record. Inbound JSON documents +- CreateItemDefinitionRequest (`POST /api/inventory/item-definitions`) + ```json + { + "itemKey": "wood", + "displayName": "Wood", + "stackable": true, + "maxStackSize": 20, + "category": "resource", + "equipSlot": null + } + ``` + +- UpdateItemDefinitionRequest (`PUT /api/inventory/item-definitions/{itemKey}`) + ```json + { + "displayName": "Wood", + "stackable": true, + "maxStackSize": 20, + "category": "resource", + "equipSlot": null + } + ``` + - GrantInventoryItemRequest (`POST /api/inventory/by-owner/{ownerType}/{ownerId}/grant`) ```json { @@ -63,6 +86,20 @@ Inbound JSON documents Only valid for items currently equipped by a character. Stored documents (MongoDB) +- ItemDefinition + ```json + { + "itemKey": "wood", + "displayName": "Wood", + "stackable": true, + "maxStackSize": 20, + "category": "resource", + "equipSlot": null, + "createdUtc": "string (ISO-8601 datetime)", + "updatedUtc": "string (ISO-8601 datetime)" + } + ``` + - InventoryItem ```json { @@ -112,6 +149,19 @@ Location stack example: ``` Outbound JSON documents +- ItemDefinitionResponse + ```json + { + "itemKey": "wood", + "displayName": "Wood", + "stackable": true, + "maxStackSize": 20, + "category": "resource", + "equipSlot": null, + "updatedUtc": "string (ISO-8601 datetime)" + } + ``` + - InventoryItemResponse ```json { @@ -160,10 +210,12 @@ Outbound JSON documents ``` Validation rules +- `itemKey` must map to an existing item definition before item instances can be created - `ownerType` must be a supported container type - `ownerId` must map to an existing owning entity where applicable - non-`SUPER` callers may only access owned character items unless explicit gameplay rules allow a world container read/write - `quantity` must be greater than `0` +- non-stackable item definitions must have `maxStackSize = 1` - non-stackable items must have `quantity = 1` - equipped items must have `slot = null` - unequipped bag items must have `equippedSlot = null` @@ -172,11 +224,11 @@ Validation rules - equipment occupancy must be unique for `(ownerType, ownerId, equippedSlot)` where `equippedSlot != null` Recommended indexes -- unique on `id` +- unique on `itemKey` for item definitions - index on `(ownerType, ownerId)` - unique on `(ownerType, ownerId, slot)` for bag slots - unique on `(ownerType, ownerId, equippedSlot)` for equipped slots -- index on `itemKey` +- index on `itemKey` for inventory items Behavior rules - moving a full non-stackable item should update its owner and slot in place @@ -184,3 +236,4 @@ Behavior rules - moving into a compatible stack should merge quantities and delete or reduce the source record - cross-owner transfer should be transactional when it mutates multiple records - auctions should reference `itemId` values directly instead of copying item state into the auction document +- the service does not auto-seed item definitions; `ItemDefinitions` must be populated explicitly diff --git a/microservices/InventoryApi/Models/CreateItemDefinitionRequest.cs b/microservices/InventoryApi/Models/CreateItemDefinitionRequest.cs new file mode 100644 index 0000000..1a141c6 --- /dev/null +++ b/microservices/InventoryApi/Models/CreateItemDefinitionRequest.cs @@ -0,0 +1,16 @@ +namespace InventoryApi.Models; + +public class CreateItemDefinitionRequest +{ + public string ItemKey { get; set; } = string.Empty; + + public string DisplayName { get; set; } = string.Empty; + + public bool Stackable { get; set; } + + public int MaxStackSize { get; set; } = 1; + + public string Category { get; set; } = "misc"; + + public string? EquipSlot { get; set; } +} diff --git a/microservices/InventoryApi/Models/ItemDefinition.cs b/microservices/InventoryApi/Models/ItemDefinition.cs new file mode 100644 index 0000000..9cb8b98 --- /dev/null +++ b/microservices/InventoryApi/Models/ItemDefinition.cs @@ -0,0 +1,33 @@ +using MongoDB.Bson.Serialization.Attributes; + +namespace InventoryApi.Models; + +[BsonIgnoreExtraElements] +public class ItemDefinition +{ + [BsonId] + [BsonElement("itemKey")] + public string ItemKey { get; set; } = string.Empty; + + [BsonElement("displayName")] + public string DisplayName { get; set; } = string.Empty; + + [BsonElement("stackable")] + public bool Stackable { get; set; } + + [BsonElement("maxStackSize")] + public int MaxStackSize { get; set; } = 1; + + [BsonElement("category")] + public string Category { get; set; } = "misc"; + + [BsonElement("equipSlot")] + [BsonIgnoreIfNull] + public string? EquipSlot { get; set; } + + [BsonElement("createdUtc")] + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; + + [BsonElement("updatedUtc")] + public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; +} diff --git a/microservices/InventoryApi/Models/ItemDefinitionResponse.cs b/microservices/InventoryApi/Models/ItemDefinitionResponse.cs new file mode 100644 index 0000000..5f8e31f --- /dev/null +++ b/microservices/InventoryApi/Models/ItemDefinitionResponse.cs @@ -0,0 +1,29 @@ +namespace InventoryApi.Models; + +public class ItemDefinitionResponse +{ + public string ItemKey { get; set; } = string.Empty; + + public string DisplayName { get; set; } = string.Empty; + + public bool Stackable { get; set; } + + public int MaxStackSize { get; set; } + + public string Category { get; set; } = "misc"; + + public string? EquipSlot { get; set; } + + public DateTime UpdatedUtc { get; set; } + + public static ItemDefinitionResponse FromModel(ItemDefinition definition) => new() + { + ItemKey = definition.ItemKey, + DisplayName = definition.DisplayName, + Stackable = definition.Stackable, + MaxStackSize = definition.MaxStackSize, + Category = definition.Category, + EquipSlot = definition.EquipSlot, + UpdatedUtc = definition.UpdatedUtc + }; +} diff --git a/microservices/InventoryApi/Models/UpdateItemDefinitionRequest.cs b/microservices/InventoryApi/Models/UpdateItemDefinitionRequest.cs new file mode 100644 index 0000000..58165ac --- /dev/null +++ b/microservices/InventoryApi/Models/UpdateItemDefinitionRequest.cs @@ -0,0 +1,14 @@ +namespace InventoryApi.Models; + +public class UpdateItemDefinitionRequest +{ + public string DisplayName { get; set; } = string.Empty; + + public bool Stackable { get; set; } + + public int MaxStackSize { get; set; } = 1; + + public string Category { get; set; } = "misc"; + + public string? EquipSlot { get; set; } +} diff --git a/microservices/InventoryApi/README.md b/microservices/InventoryApi/README.md index 9afa356..b95d50c 100644 --- a/microservices/InventoryApi/README.md +++ b/microservices/InventoryApi/README.md @@ -104,6 +104,14 @@ Optional future fields: - slot occupancy must be unique per `(ownerType, ownerId, slot)` - all mutating endpoints should be idempotent where practical +## Definition source +Item behavior is fully data-driven through the `ItemDefinitions` collection inside `InventoryApi`. + +That means: +- no hardcoded stack-size rules +- no automatic item-definition seeding +- item instances can only be created for `itemKey` values that already exist in `ItemDefinitions` + ## Client shape The Godot client should fetch all items for the currently relevant owner and group them into a bag view locally. diff --git a/microservices/InventoryApi/Services/InventoryStore.cs b/microservices/InventoryApi/Services/InventoryStore.cs index 823ec97..8f2cea6 100644 --- a/microservices/InventoryApi/Services/InventoryStore.cs +++ b/microservices/InventoryApi/Services/InventoryStore.cs @@ -8,17 +8,17 @@ public class InventoryStore { private const string CharacterOwnerType = "character"; private const string LocationOwnerType = "location"; - private const string ItemIdIndexName = "item_id_unique"; private const string OwnerIndexName = "owner_type_1_owner_id_1"; private const string SlotIndexName = "owner_type_1_owner_id_1_slot_1"; private const string EquippedSlotIndexName = "owner_type_1_owner_id_1_equipped_slot_1"; + private const string DefinitionCategoryIndexName = "category_1"; private readonly IMongoCollection _items; + private readonly IMongoCollection _definitions; private readonly IMongoCollection _characters; private readonly IMongoCollection _locations; private readonly IMongoClient _client; private readonly string _dbName; - private readonly HashSet _stackableItemKeys = ["wood", "stone", "small_health_potion"]; public InventoryStore(IConfiguration cfg) { @@ -27,6 +27,7 @@ public class InventoryStore _client = new MongoClient(cs); var db = _client.GetDatabase(_dbName); _items = db.GetCollection("InventoryItems"); + _definitions = db.GetCollection("ItemDefinitions"); _characters = db.GetCollection("Characters"); _locations = db.GetCollection("Locations"); @@ -84,30 +85,92 @@ public class InventoryStore .ThenBy(i => i.ItemKey) .ToListAsync(); - public async Task> GrantAsync(OwnerAccessResult owner, GrantInventoryItemRequest req) - { - var normalizedKey = req.ItemKey.Trim(); - if (IsStackable(normalizedKey)) - { - var targetSlot = req.PreferredSlot ?? await FindFirstOpenSlotAsync(owner.OwnerType, owner.OwnerId); - var existing = await FindStackAsync(owner.OwnerType, owner.OwnerId, normalizedKey, targetSlot); - if (existing is not null) - { - existing.Quantity += req.Quantity; - existing.UpdatedUtc = DateTime.UtcNow; - await ReplaceItemAsync(existing); - return await GetByOwnerAsync(owner.OwnerType, owner.OwnerId); - } + public Task> ListItemDefinitionsAsync() => + _definitions.Find(Builders.Filter.Empty).SortBy(d => d.ItemKey).ToListAsync(); - await InsertItemAsync(new InventoryItem + public async Task GetItemDefinitionAsync(string itemKey) => + await _definitions.Find(d => d.ItemKey == NormalizeItemKey(itemKey)).FirstOrDefaultAsync(); + + public async Task CreateItemDefinitionAsync(CreateItemDefinitionRequest req) + { + var itemKey = NormalizeItemKey(req.ItemKey); + var existing = await GetItemDefinitionAsync(itemKey); + if (existing is not null) + return null; + + var definition = new ItemDefinition + { + ItemKey = itemKey, + DisplayName = req.DisplayName.Trim(), + Stackable = req.Stackable, + MaxStackSize = req.Stackable ? req.MaxStackSize : 1, + Category = string.IsNullOrWhiteSpace(req.Category) ? "misc" : req.Category.Trim().ToLowerInvariant(), + EquipSlot = string.IsNullOrWhiteSpace(req.EquipSlot) ? null : req.EquipSlot.Trim().ToLowerInvariant(), + CreatedUtc = DateTime.UtcNow, + UpdatedUtc = DateTime.UtcNow + }; + + await _definitions.InsertOneAsync(definition); + return definition; + } + + public async Task UpdateItemDefinitionAsync(string itemKey, UpdateItemDefinitionRequest req) + { + var existing = await GetItemDefinitionAsync(itemKey); + if (existing is null) + return null; + + existing.DisplayName = req.DisplayName.Trim(); + existing.Stackable = req.Stackable; + existing.MaxStackSize = req.Stackable ? req.MaxStackSize : 1; + existing.Category = string.IsNullOrWhiteSpace(req.Category) ? "misc" : req.Category.Trim().ToLowerInvariant(); + existing.EquipSlot = string.IsNullOrWhiteSpace(req.EquipSlot) ? null : req.EquipSlot.Trim().ToLowerInvariant(); + existing.UpdatedUtc = DateTime.UtcNow; + + await _definitions.ReplaceOneAsync(d => d.ItemKey == existing.ItemKey, existing); + return existing; + } + + public async Task> GrantAsync(OwnerAccessResult owner, GrantInventoryItemRequest req, ItemDefinition definition) + { + var normalizedKey = NormalizeItemKey(req.ItemKey); + if (definition.Stackable) + { + var remaining = req.Quantity; + var targetSlot = req.PreferredSlot; + while (remaining > 0) { - ItemKey = normalizedKey, - Quantity = req.Quantity, - OwnerType = owner.OwnerType, - OwnerId = owner.OwnerId, - OwnerUserId = owner.OwnerUserId, - Slot = targetSlot - }); + var slot = targetSlot ?? await FindFirstOpenSlotAsync(owner.OwnerType, owner.OwnerId); + var existing = await FindStackAsync(owner.OwnerType, owner.OwnerId, normalizedKey, slot); + if (existing is not null) + { + var availableSpace = definition.MaxStackSize - existing.Quantity; + if (availableSpace > 0) + { + var added = Math.Min(remaining, availableSpace); + existing.Quantity += added; + existing.UpdatedUtc = DateTime.UtcNow; + await ReplaceItemAsync(existing); + remaining -= added; + } + } + else + { + var stackQuantity = Math.Min(remaining, definition.MaxStackSize); + await InsertItemAsync(new InventoryItem + { + ItemKey = normalizedKey, + Quantity = stackQuantity, + OwnerType = owner.OwnerType, + OwnerId = owner.OwnerId, + OwnerUserId = owner.OwnerUserId, + Slot = slot + }); + remaining -= stackQuantity; + } + + targetSlot = null; + } return await GetByOwnerAsync(owner.OwnerType, owner.OwnerId); } @@ -141,21 +204,29 @@ public class InventoryStore if (quantity <= 0 || quantity > item.Quantity) return new InventoryMutationResult { Status = InventoryMutationStatus.Invalid }; + var definition = await GetItemDefinitionAsync(item.ItemKey); + if (definition is null) + return new InventoryMutationResult { Status = InventoryMutationStatus.Invalid }; + var existing = await FindItemBySlotAsync(owner.OwnerType, owner.OwnerId, req.ToSlot); if (existing is not null && existing.Id != item.Id) { - if (!CanMerge(item, existing)) + if (!CanMerge(item, existing, definition)) return new InventoryMutationResult { Status = InventoryMutationStatus.Conflict }; - existing.Quantity += quantity; + var transferable = Math.Min(quantity, definition.MaxStackSize - existing.Quantity); + if (transferable <= 0) + return new InventoryMutationResult { Status = InventoryMutationStatus.Conflict }; + + existing.Quantity += transferable; existing.UpdatedUtc = DateTime.UtcNow; await ReplaceItemAsync(existing); - if (quantity == item.Quantity) + if (transferable == item.Quantity) await DeleteItemAsync(item.Id); else { - item.Quantity -= quantity; + item.Quantity -= transferable; item.UpdatedUtc = DateTime.UtcNow; await ReplaceItemAsync(item); } @@ -219,9 +290,16 @@ public class InventoryStore return new InventoryMutationResult { Status = InventoryMutationStatus.Invalid }; } + var definition = await GetItemDefinitionAsync(item.ItemKey); + if (definition is null) + { + await session.AbortTransactionAsync(); + return new InventoryMutationResult { Status = InventoryMutationStatus.Invalid }; + } + var toSlot = req.ToSlot ?? await FindFirstOpenSlotAsync(toOwner.OwnerType, toOwner.OwnerId, session); var target = await FindItemBySlotAsync(toOwner.OwnerType, toOwner.OwnerId, toSlot, session); - if (target is not null && !CanMerge(item, target)) + if (target is not null && !CanMerge(item, target, definition)) { await session.AbortTransactionAsync(); return new InventoryMutationResult { Status = InventoryMutationStatus.Conflict }; @@ -229,15 +307,22 @@ public class InventoryStore if (target is not null) { - target.Quantity += quantity; + var transferable = Math.Min(quantity, definition.MaxStackSize - target.Quantity); + if (transferable <= 0) + { + await session.AbortTransactionAsync(); + return new InventoryMutationResult { Status = InventoryMutationStatus.Conflict }; + } + + target.Quantity += transferable; target.UpdatedUtc = DateTime.UtcNow; await ReplaceItemAsync(target, session); - if (quantity == item.Quantity) + if (transferable == item.Quantity) await DeleteItemAsync(item.Id, session); else { - item.Quantity -= quantity; + item.Quantity -= transferable; item.UpdatedUtc = DateTime.UtcNow; await ReplaceItemAsync(item, session); } @@ -326,6 +411,11 @@ public class InventoryStore return new InventoryMutationResult { Status = InventoryMutationStatus.ItemNotFound }; if (item.OwnerType != CharacterOwnerType || item.OwnerId != ownerId || item.Slot is null) return new InventoryMutationResult { Status = InventoryMutationStatus.Invalid }; + var definition = await GetItemDefinitionAsync(item.ItemKey); + if (definition is null) + return new InventoryMutationResult { Status = InventoryMutationStatus.Invalid }; + if (!string.Equals(definition.EquipSlot, equipmentSlot.Trim(), StringComparison.OrdinalIgnoreCase)) + return new InventoryMutationResult { Status = InventoryMutationStatus.Invalid }; var equipped = await _items.Find(i => i.OwnerType == CharacterOwnerType && @@ -415,7 +505,7 @@ public class InventoryStore private async Task InsertItemAsync(InventoryItem item, IClientSessionHandle? session = null) { - item.ItemKey = item.ItemKey.Trim(); + item.ItemKey = NormalizeItemKey(item.ItemKey); item.OwnerType = item.OwnerType.Trim().ToLowerInvariant(); item.CreatedUtc = DateTime.UtcNow; item.UpdatedUtc = item.CreatedUtc; @@ -442,20 +532,16 @@ public class InventoryStore : _items.DeleteOneAsync(session, i => i.Id == itemId); } - private bool IsStackable(string itemKey) => _stackableItemKeys.Contains(itemKey.Trim().ToLowerInvariant()); + private static string NormalizeItemKey(string itemKey) => itemKey.Trim().ToLowerInvariant(); - private bool CanMerge(InventoryItem source, InventoryItem target) => + private static bool CanMerge(InventoryItem source, InventoryItem target, ItemDefinition definition) => source.ItemKey == target.ItemKey && source.EquippedSlot is null && target.EquippedSlot is null && - IsStackable(source.ItemKey); + definition.Stackable; private void EnsureIndexes() { - _items.Indexes.CreateOne(new CreateIndexModel( - Builders.IndexKeys.Ascending(i => i.Id), - new CreateIndexOptions { Unique = true, Name = ItemIdIndexName })); - _items.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(i => i.OwnerType).Ascending(i => i.OwnerId), new CreateIndexOptions { Name = OwnerIndexName })); @@ -466,7 +552,7 @@ public class InventoryStore { Unique = true, Name = SlotIndexName, - PartialFilterExpression = Builders.Filter.Ne(i => i.Slot, null) + PartialFilterExpression = new BsonDocument("slot", new BsonDocument("$exists", true)) })); _items.Indexes.CreateOne(new CreateIndexModel( @@ -475,8 +561,12 @@ public class InventoryStore { Unique = true, Name = EquippedSlotIndexName, - PartialFilterExpression = Builders.Filter.Ne(i => i.EquippedSlot, null) + PartialFilterExpression = new BsonDocument("equippedSlot", new BsonDocument("$exists", true)) })); + + _definitions.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(d => d.Category), + new CreateIndexOptions { Name = DefinitionCategoryIndexName })); } private async Task FindItemBySlotNoSessionAsync(string ownerType, string ownerId, int slot) => @@ -491,6 +581,7 @@ public class InventoryStore private async Task FindStackWithSessionAsync(string ownerType, string ownerId, string itemKey, int slot, IClientSessionHandle session) => await _items.Find(session, i => i.OwnerType == ownerType && i.OwnerId == ownerId && i.Slot == slot && i.ItemKey == itemKey && i.EquippedSlot == null).FirstOrDefaultAsync(); + [MongoDB.Bson.Serialization.Attributes.BsonIgnoreExtraElements] private class CharacterOwnerDocument { [MongoDB.Bson.Serialization.Attributes.BsonId] @@ -500,6 +591,7 @@ public class InventoryStore public string OwnerUserId { get; set; } = string.Empty; } + [MongoDB.Bson.Serialization.Attributes.BsonIgnoreExtraElements] private class LocationOwnerDocument { [MongoDB.Bson.Serialization.Attributes.BsonId]