using InventoryApi.Models; using MongoDB.Bson; using MongoDB.Driver; namespace InventoryApi.Services; public class InventoryStore { private const string CharacterOwnerType = "character"; private const string LocationOwnerType = "location"; private const int CharacterInventorySlotCount = 6; 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; public sealed record GrantResult(List Items, int RequestedQuantity, int GrantedQuantity, int OverflowQuantity); public InventoryStore(IConfiguration cfg) { var cs = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017"; _dbName = cfg["MongoDB:DatabaseName"] ?? "promiscuity"; _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"); EnsureIndexes(); } public async Task ResolveOwnerAsync(string ownerType, string ownerId, string userId, bool allowAnyOwner) { var normalizedOwnerType = NormalizeOwnerType(ownerType); if (normalizedOwnerType is null) return new OwnerAccessResult { IsSupported = false }; if (normalizedOwnerType == CharacterOwnerType) { var character = await _characters.Find(c => c.Id == ownerId).FirstOrDefaultAsync(); if (character is null) { return new OwnerAccessResult { IsSupported = true, Exists = false, OwnerType = normalizedOwnerType, OwnerId = ownerId }; } var authorized = allowAnyOwner || character.OwnerUserId == userId; return new OwnerAccessResult { IsSupported = true, Exists = true, IsAuthorized = authorized, OwnerType = normalizedOwnerType, OwnerId = ownerId, OwnerUserId = character.OwnerUserId }; } var location = await _locations.Find(l => l.Id == ownerId).FirstOrDefaultAsync(); return new OwnerAccessResult { IsSupported = true, Exists = location is not null, IsAuthorized = location is not null, OwnerType = normalizedOwnerType, OwnerId = ownerId, OwnerUserId = null }; } public Task> GetByOwnerAsync(string ownerType, string ownerId) => _items.Find(i => i.OwnerType == ownerType && i.OwnerId == ownerId) .SortBy(i => i.EquippedSlot) .ThenBy(i => i.Slot) .ThenBy(i => i.ItemKey) .ToListAsync(); public Task> ListItemDefinitionsAsync() => _definitions.Find(Builders.Filter.Empty).SortBy(d => d.ItemKey).ToListAsync(); 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); var remaining = req.Quantity; if (definition.Stackable) { var targetSlot = req.PreferredSlot; var existingStacks = await _items.Find(i => i.OwnerType == owner.OwnerType && i.OwnerId == owner.OwnerId && i.ItemKey == normalizedKey && i.EquippedSlot == null && i.Slot != null) .SortBy(i => i.Slot) .ToListAsync(); foreach (var existing in existingStacks) { if (remaining <= 0) break; var availableSpace = definition.MaxStackSize - existing.Quantity; if (availableSpace <= 0) continue; var added = Math.Min(remaining, availableSpace); existing.Quantity += added; existing.UpdatedUtc = DateTime.UtcNow; await ReplaceItemAsync(existing); remaining -= added; } while (remaining > 0) { var slot = targetSlot ?? await FindFirstOpenSlotAsync(owner.OwnerType, owner.OwnerId); if (slot is null) break; 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.Value }); remaining -= stackQuantity; targetSlot = null; } var stackItems = await GetByOwnerAsync(owner.OwnerType, owner.OwnerId); return new GrantResult(stackItems, req.Quantity, req.Quantity - remaining, remaining); } var nextPreferredSlot = req.PreferredSlot; while (remaining > 0) { var slot = nextPreferredSlot ?? await FindFirstOpenSlotAsync(owner.OwnerType, owner.OwnerId); if (slot is null) break; await InsertItemAsync(new InventoryItem { ItemKey = normalizedKey, Quantity = 1, OwnerType = owner.OwnerType, OwnerId = owner.OwnerId, OwnerUserId = owner.OwnerUserId, Slot = slot.Value }); remaining -= 1; nextPreferredSlot = null; } var nonStackItems = await GetByOwnerAsync(owner.OwnerType, owner.OwnerId); return new GrantResult(nonStackItems, req.Quantity, req.Quantity - remaining, remaining); } public async Task MoveAsync(OwnerAccessResult owner, MoveInventoryItemRequest req) { var item = await _items.Find(i => i.Id == req.ItemId).FirstOrDefaultAsync(); if (item is null) return new InventoryMutationResult { Status = InventoryMutationStatus.ItemNotFound }; if (item.OwnerType != owner.OwnerType || item.OwnerId != owner.OwnerId || item.EquippedSlot is not null) return new InventoryMutationResult { Status = InventoryMutationStatus.Invalid }; var quantity = req.Quantity ?? item.Quantity; 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, definition)) return new InventoryMutationResult { Status = InventoryMutationStatus.Conflict }; 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 (transferable == item.Quantity) await DeleteItemAsync(item.Id); else { item.Quantity -= transferable; item.UpdatedUtc = DateTime.UtcNow; await ReplaceItemAsync(item); } } else if (quantity == item.Quantity) { item.Slot = req.ToSlot; item.UpdatedUtc = DateTime.UtcNow; await ReplaceItemAsync(item); } else { item.Quantity -= quantity; item.UpdatedUtc = DateTime.UtcNow; await ReplaceItemAsync(item); await InsertItemAsync(new InventoryItem { ItemKey = item.ItemKey, Quantity = quantity, OwnerType = item.OwnerType, OwnerId = item.OwnerId, OwnerUserId = item.OwnerUserId, Slot = req.ToSlot }); } return new InventoryMutationResult { Status = InventoryMutationStatus.Ok, OwnerType = owner.OwnerType, OwnerId = owner.OwnerId, Items = await GetByOwnerAsync(owner.OwnerType, owner.OwnerId) }; } public async Task TransferAsync(OwnerAccessResult fromOwner, OwnerAccessResult toOwner, TransferInventoryItemRequest req) { using var session = await _client.StartSessionAsync(); session.StartTransaction(); try { var item = await _items.Find(session, i => i.Id == req.ItemId).FirstOrDefaultAsync(); if (item is null) { await session.AbortTransactionAsync(); return new InventoryMutationResult { Status = InventoryMutationStatus.ItemNotFound }; } if (item.OwnerType != fromOwner.OwnerType || item.OwnerId != fromOwner.OwnerId || item.EquippedSlot is not null) { await session.AbortTransactionAsync(); return new InventoryMutationResult { Status = InventoryMutationStatus.Invalid }; } var quantity = req.Quantity ?? item.Quantity; if (quantity <= 0 || quantity > item.Quantity) { await session.AbortTransactionAsync(); 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); if (toSlot is null) { await session.AbortTransactionAsync(); return new InventoryMutationResult { Status = InventoryMutationStatus.Conflict }; } var target = await FindItemBySlotAsync(toOwner.OwnerType, toOwner.OwnerId, toSlot.Value, session); if (target is not null && !CanMerge(item, target, definition)) { await session.AbortTransactionAsync(); return new InventoryMutationResult { Status = InventoryMutationStatus.Conflict }; } if (target is not null) { 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 (transferable == item.Quantity) await DeleteItemAsync(item.Id, session); else { item.Quantity -= transferable; item.UpdatedUtc = DateTime.UtcNow; await ReplaceItemAsync(item, session); } } else if (quantity == item.Quantity) { item.OwnerType = toOwner.OwnerType; item.OwnerId = toOwner.OwnerId; item.OwnerUserId = toOwner.OwnerUserId; item.Slot = toSlot; item.EquippedSlot = null; item.UpdatedUtc = DateTime.UtcNow; await ReplaceItemAsync(item, session); } else { item.Quantity -= quantity; item.UpdatedUtc = DateTime.UtcNow; await ReplaceItemAsync(item, session); await InsertItemAsync(new InventoryItem { ItemKey = item.ItemKey, Quantity = quantity, OwnerType = toOwner.OwnerType, OwnerId = toOwner.OwnerId, OwnerUserId = toOwner.OwnerUserId, Slot = toSlot }, session); } await session.CommitTransactionAsync(); var fromItems = await GetByOwnerAsync(fromOwner.OwnerType, fromOwner.OwnerId); var toItems = await GetByOwnerAsync(toOwner.OwnerType, toOwner.OwnerId); return new InventoryMutationResult { Status = InventoryMutationStatus.Ok, Items = fromItems.Concat(toItems).ToList() }; } catch { await session.AbortTransactionAsync(); throw; } } public async Task ConsumeAsync(string itemId, int quantity, string userId, bool allowAnyOwner) { var item = await _items.Find(i => i.Id == itemId).FirstOrDefaultAsync(); if (item is null) return new InventoryMutationResult { Status = InventoryMutationStatus.ItemNotFound }; var access = await ResolveOwnerAsync(item.OwnerType, item.OwnerId, userId, allowAnyOwner); if (!access.Exists || !access.IsAuthorized) return new InventoryMutationResult { Status = InventoryMutationStatus.Invalid }; if (quantity > item.Quantity) return new InventoryMutationResult { Status = InventoryMutationStatus.Invalid }; if (quantity == item.Quantity) await DeleteItemAsync(item.Id); else { item.Quantity -= quantity; item.UpdatedUtc = DateTime.UtcNow; await ReplaceItemAsync(item); } return new InventoryMutationResult { Status = InventoryMutationStatus.Ok, OwnerType = item.OwnerType, OwnerId = item.OwnerId, Items = await GetByOwnerAsync(item.OwnerType, item.OwnerId) }; } public async Task EquipAsync(string itemId, string ownerId, string equipmentSlot, string userId, bool allowAnyOwner) { var owner = await ResolveOwnerAsync(CharacterOwnerType, ownerId, userId, allowAnyOwner); if (!owner.Exists || !owner.IsAuthorized) return new InventoryMutationResult { Status = InventoryMutationStatus.Invalid }; var item = await _items.Find(i => i.Id == itemId).FirstOrDefaultAsync(); if (item is null) 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 && i.OwnerId == ownerId && i.EquippedSlot == equipmentSlot).FirstOrDefaultAsync(); if (equipped is not null && equipped.Id != item.Id) return new InventoryMutationResult { Status = InventoryMutationStatus.Conflict }; item.Slot = null; item.EquippedSlot = equipmentSlot.Trim(); item.UpdatedUtc = DateTime.UtcNow; await ReplaceItemAsync(item); return new InventoryMutationResult { Status = InventoryMutationStatus.Ok, OwnerType = item.OwnerType, OwnerId = item.OwnerId, Items = await GetByOwnerAsync(item.OwnerType, item.OwnerId) }; } public async Task UnequipAsync(string itemId, string ownerId, int? preferredSlot, string userId, bool allowAnyOwner) { var owner = await ResolveOwnerAsync(CharacterOwnerType, ownerId, userId, allowAnyOwner); if (!owner.Exists || !owner.IsAuthorized) return new InventoryMutationResult { Status = InventoryMutationStatus.Invalid }; var item = await _items.Find(i => i.Id == itemId).FirstOrDefaultAsync(); if (item is null) return new InventoryMutationResult { Status = InventoryMutationStatus.ItemNotFound }; if (item.OwnerType != CharacterOwnerType || item.OwnerId != ownerId || string.IsNullOrWhiteSpace(item.EquippedSlot)) return new InventoryMutationResult { Status = InventoryMutationStatus.Invalid }; var slot = preferredSlot ?? await FindFirstOpenSlotAsync(item.OwnerType, item.OwnerId); if (slot is null) return new InventoryMutationResult { Status = InventoryMutationStatus.Conflict }; var existing = await FindItemBySlotAsync(item.OwnerType, item.OwnerId, slot.Value); if (existing is not null && existing.Id != item.Id) return new InventoryMutationResult { Status = InventoryMutationStatus.Conflict }; item.EquippedSlot = null; item.Slot = slot.Value; item.UpdatedUtc = DateTime.UtcNow; await ReplaceItemAsync(item); return new InventoryMutationResult { Status = InventoryMutationStatus.Ok, OwnerType = item.OwnerType, OwnerId = item.OwnerId, Items = await GetByOwnerAsync(item.OwnerType, item.OwnerId) }; } private static string? NormalizeOwnerType(string ownerType) { var normalized = ownerType.Trim().ToLowerInvariant(); return normalized switch { CharacterOwnerType => CharacterOwnerType, LocationOwnerType => LocationOwnerType, _ => null }; } private async Task FindFirstOpenSlotAsync(string ownerType, string ownerId, IClientSessionHandle? session = null) { var items = session is null ? await GetByOwnerAsync(ownerType, ownerId) : await _items.Find(session, i => i.OwnerType == ownerType && i.OwnerId == ownerId).ToListAsync(); var usedSlots = items.Where(i => i.Slot.HasValue).Select(i => i.Slot!.Value).ToHashSet(); var maxSlotCount = GetMaxSlotCount(ownerType); var slot = 0; while (usedSlots.Contains(slot)) slot += 1; if (maxSlotCount.HasValue && slot >= maxSlotCount.Value) return null; return slot; } private Task FindItemBySlotAsync(string ownerType, string ownerId, int slot, IClientSessionHandle? session = null) => session is null ? FindItemBySlotNoSessionAsync(ownerType, ownerId, slot) : FindItemBySlotWithSessionAsync(ownerType, ownerId, slot, session); private Task FindStackAsync(string ownerType, string ownerId, string itemKey, int slot, IClientSessionHandle? session = null) => session is null ? FindStackNoSessionAsync(ownerType, ownerId, itemKey, slot) : FindStackWithSessionAsync(ownerType, ownerId, itemKey, slot, session); private async Task InsertItemAsync(InventoryItem item, IClientSessionHandle? session = null) { item.ItemKey = NormalizeItemKey(item.ItemKey); item.OwnerType = item.OwnerType.Trim().ToLowerInvariant(); item.CreatedUtc = DateTime.UtcNow; item.UpdatedUtc = item.CreatedUtc; if (session is null) await _items.InsertOneAsync(item); else await _items.InsertOneAsync(session, item); } private async Task ReplaceItemAsync(InventoryItem item, IClientSessionHandle? session = null) { item.UpdatedUtc = DateTime.UtcNow; if (session is null) await _items.ReplaceOneAsync(i => i.Id == item.Id, item); else await _items.ReplaceOneAsync(session, i => i.Id == item.Id, item); } private Task DeleteItemAsync(string itemId, IClientSessionHandle? session = null) { return session is null ? _items.DeleteOneAsync(i => i.Id == itemId) : _items.DeleteOneAsync(session, i => i.Id == itemId); } private static string NormalizeItemKey(string itemKey) => itemKey.Trim().ToLowerInvariant(); private static bool CanMerge(InventoryItem source, InventoryItem target, ItemDefinition definition) => source.ItemKey == target.ItemKey && source.EquippedSlot is null && target.EquippedSlot is null && definition.Stackable; private static int? GetMaxSlotCount(string ownerType) => string.Equals(ownerType, CharacterOwnerType, StringComparison.OrdinalIgnoreCase) ? CharacterInventorySlotCount : null; private void EnsureIndexes() { _items.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(i => i.OwnerType).Ascending(i => i.OwnerId), new CreateIndexOptions { Name = OwnerIndexName })); _items.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(i => i.OwnerType).Ascending(i => i.OwnerId).Ascending(i => i.Slot), new CreateIndexOptions { Unique = true, Name = SlotIndexName, PartialFilterExpression = new BsonDocument("slot", new BsonDocument("$exists", true)) })); _items.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(i => i.OwnerType).Ascending(i => i.OwnerId).Ascending(i => i.EquippedSlot), new CreateIndexOptions { Unique = true, Name = EquippedSlotIndexName, 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) => await _items.Find(i => i.OwnerType == ownerType && i.OwnerId == ownerId && i.Slot == slot).FirstOrDefaultAsync(); private async Task FindItemBySlotWithSessionAsync(string ownerType, string ownerId, int slot, IClientSessionHandle session) => await _items.Find(session, i => i.OwnerType == ownerType && i.OwnerId == ownerId && i.Slot == slot).FirstOrDefaultAsync(); private async Task FindStackNoSessionAsync(string ownerType, string ownerId, string itemKey, int slot) => await _items.Find(i => i.OwnerType == ownerType && i.OwnerId == ownerId && i.Slot == slot && i.ItemKey == itemKey && i.EquippedSlot == null).FirstOrDefaultAsync(); 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] [MongoDB.Bson.Serialization.Attributes.BsonRepresentation(MongoDB.Bson.BsonType.ObjectId)] public string? Id { get; set; } public string OwnerUserId { get; set; } = string.Empty; } [MongoDB.Bson.Serialization.Attributes.BsonIgnoreExtraElements] private class LocationOwnerDocument { [MongoDB.Bson.Serialization.Attributes.BsonId] [MongoDB.Bson.Serialization.Attributes.BsonRepresentation(MongoDB.Bson.BsonType.ObjectId)] public string? Id { get; set; } } }