All checks were successful
Deploy Promiscuity Auth API / deploy (push) Successful in 47s
Deploy Promiscuity Character API / deploy (push) Successful in 47s
Deploy Promiscuity Inventory API / deploy (push) Successful in 59s
Deploy Promiscuity Locations API / deploy (push) Successful in 46s
k8s smoke test / test (push) Successful in 9s
664 lines
28 KiB
C#
664 lines
28 KiB
C#
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<InventoryItem> _items;
|
|
private readonly IMongoCollection<ItemDefinition> _definitions;
|
|
private readonly IMongoCollection<CharacterOwnerDocument> _characters;
|
|
private readonly IMongoCollection<LocationOwnerDocument> _locations;
|
|
private readonly IMongoClient _client;
|
|
private readonly string _dbName;
|
|
|
|
public sealed record GrantResult(List<InventoryItem> 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<InventoryItem>("InventoryItems");
|
|
_definitions = db.GetCollection<ItemDefinition>("ItemDefinitions");
|
|
_characters = db.GetCollection<CharacterOwnerDocument>("Characters");
|
|
_locations = db.GetCollection<LocationOwnerDocument>("Locations");
|
|
|
|
EnsureIndexes();
|
|
}
|
|
|
|
public async Task<OwnerAccessResult> 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<List<InventoryItem>> 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 async Task<Dictionary<string, List<InventoryItem>>> GetByOwnersAsync(string ownerType, IEnumerable<string> ownerIds)
|
|
{
|
|
var normalizedOwnerType = NormalizeOwnerType(ownerType);
|
|
if (normalizedOwnerType is null)
|
|
return [];
|
|
|
|
var ids = ownerIds
|
|
.Where(id => !string.IsNullOrWhiteSpace(id))
|
|
.Select(id => id.Trim())
|
|
.Distinct(StringComparer.Ordinal)
|
|
.ToList();
|
|
if (ids.Count == 0)
|
|
return [];
|
|
|
|
var items = await _items.Find(i => i.OwnerType == normalizedOwnerType && ids.Contains(i.OwnerId))
|
|
.SortBy(i => i.OwnerId)
|
|
.ThenBy(i => i.EquippedSlot)
|
|
.ThenBy(i => i.Slot)
|
|
.ThenBy(i => i.ItemKey)
|
|
.ToListAsync();
|
|
|
|
return items
|
|
.GroupBy(item => item.OwnerId, StringComparer.Ordinal)
|
|
.ToDictionary(group => group.Key, group => group.ToList(), StringComparer.Ordinal);
|
|
}
|
|
|
|
public Task<List<ItemDefinition>> ListItemDefinitionsAsync() =>
|
|
_definitions.Find(Builders<ItemDefinition>.Filter.Empty).SortBy(d => d.ItemKey).ToListAsync();
|
|
|
|
public async Task<ItemDefinition?> GetItemDefinitionAsync(string itemKey) =>
|
|
await _definitions.Find(d => d.ItemKey == NormalizeItemKey(itemKey)).FirstOrDefaultAsync();
|
|
|
|
public async Task<ItemDefinition?> 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<ItemDefinition?> 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<GrantResult> 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<InventoryMutationResult> 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<InventoryMutationResult> TransferAsync(OwnerAccessResult fromOwner, OwnerAccessResult toOwner, TransferInventoryItemRequest 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 != fromOwner.OwnerType || item.OwnerId != fromOwner.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 };
|
|
|
|
InventoryItem? target = null;
|
|
int? toSlot = req.ToSlot;
|
|
if (toSlot is not null)
|
|
{
|
|
target = await FindItemBySlotAsync(toOwner.OwnerType, toOwner.OwnerId, toSlot.Value);
|
|
if (target is not null && !CanMerge(item, target, definition))
|
|
return new InventoryMutationResult { Status = InventoryMutationStatus.Conflict };
|
|
}
|
|
else if (definition.Stackable)
|
|
{
|
|
target = await FindMergeTargetAsync(toOwner.OwnerType, toOwner.OwnerId, item.ItemKey, definition.MaxStackSize);
|
|
if (target is not null)
|
|
toSlot = target.Slot;
|
|
}
|
|
|
|
if (toSlot is null)
|
|
{
|
|
toSlot = await FindFirstOpenSlotAsync(toOwner.OwnerType, toOwner.OwnerId);
|
|
if (toSlot is null)
|
|
return new InventoryMutationResult { Status = InventoryMutationStatus.Conflict };
|
|
target = null;
|
|
}
|
|
|
|
if (target is not null)
|
|
{
|
|
var transferable = Math.Min(quantity, definition.MaxStackSize - target.Quantity);
|
|
if (transferable <= 0)
|
|
{
|
|
return new InventoryMutationResult { Status = InventoryMutationStatus.Conflict };
|
|
}
|
|
|
|
target.Quantity += transferable;
|
|
target.UpdatedUtc = DateTime.UtcNow;
|
|
await ReplaceItemAsync(target);
|
|
|
|
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.OwnerType = toOwner.OwnerType;
|
|
item.OwnerId = toOwner.OwnerId;
|
|
item.OwnerUserId = toOwner.OwnerUserId;
|
|
item.Slot = toSlot;
|
|
item.EquippedSlot = null;
|
|
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 = toOwner.OwnerType,
|
|
OwnerId = toOwner.OwnerId,
|
|
OwnerUserId = toOwner.OwnerUserId,
|
|
Slot = toSlot
|
|
});
|
|
}
|
|
|
|
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()
|
|
};
|
|
}
|
|
|
|
public async Task<InventoryMutationResult> 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<InventoryMutationResult> 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<InventoryMutationResult> 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<int?> 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 async Task<InventoryItem?> FindMergeTargetAsync(string ownerType, string ownerId, string itemKey, int maxStackSize)
|
|
{
|
|
var normalizedItemKey = NormalizeItemKey(itemKey);
|
|
return await _items.Find(i =>
|
|
i.OwnerType == ownerType &&
|
|
i.OwnerId == ownerId &&
|
|
i.ItemKey == normalizedItemKey &&
|
|
i.EquippedSlot == null &&
|
|
i.Slot != null &&
|
|
i.Quantity < maxStackSize)
|
|
.SortBy(i => i.Slot)
|
|
.FirstOrDefaultAsync();
|
|
}
|
|
|
|
private Task<InventoryItem?> FindItemBySlotAsync(string ownerType, string ownerId, int slot, IClientSessionHandle? session = null)
|
|
=> session is null
|
|
? FindItemBySlotNoSessionAsync(ownerType, ownerId, slot)
|
|
: FindItemBySlotWithSessionAsync(ownerType, ownerId, slot, session);
|
|
|
|
private Task<InventoryItem?> 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<InventoryItem>(
|
|
Builders<InventoryItem>.IndexKeys.Ascending(i => i.OwnerType).Ascending(i => i.OwnerId),
|
|
new CreateIndexOptions { Name = OwnerIndexName }));
|
|
|
|
_items.Indexes.CreateOne(new CreateIndexModel<InventoryItem>(
|
|
Builders<InventoryItem>.IndexKeys.Ascending(i => i.OwnerType).Ascending(i => i.OwnerId).Ascending(i => i.Slot),
|
|
new CreateIndexOptions<InventoryItem>
|
|
{
|
|
Unique = true,
|
|
Name = SlotIndexName,
|
|
PartialFilterExpression = new BsonDocument("slot", new BsonDocument("$exists", true))
|
|
}));
|
|
|
|
_items.Indexes.CreateOne(new CreateIndexModel<InventoryItem>(
|
|
Builders<InventoryItem>.IndexKeys.Ascending(i => i.OwnerType).Ascending(i => i.OwnerId).Ascending(i => i.EquippedSlot),
|
|
new CreateIndexOptions<InventoryItem>
|
|
{
|
|
Unique = true,
|
|
Name = EquippedSlotIndexName,
|
|
PartialFilterExpression = new BsonDocument("equippedSlot", new BsonDocument("$exists", true))
|
|
}));
|
|
|
|
_definitions.Indexes.CreateOne(new CreateIndexModel<ItemDefinition>(
|
|
Builders<ItemDefinition>.IndexKeys.Ascending(d => d.Category),
|
|
new CreateIndexOptions { Name = DefinitionCategoryIndexName }));
|
|
}
|
|
|
|
private async Task<InventoryItem?> FindItemBySlotNoSessionAsync(string ownerType, string ownerId, int slot) =>
|
|
await _items.Find(i => i.OwnerType == ownerType && i.OwnerId == ownerId && i.Slot == slot).FirstOrDefaultAsync();
|
|
|
|
private async Task<InventoryItem?> 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<InventoryItem?> 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<InventoryItem?> 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; }
|
|
}
|
|
}
|