Inventory enhancements
Deploy Promiscuity Auth API / deploy (push) Successful in 47s
Deploy Promiscuity Character API / deploy (push) Successful in 46s
Deploy Promiscuity Inventory API / deploy (push) Successful in 58s
Deploy Promiscuity Locations API / deploy (push) Successful in 44s
k8s smoke test / test (push) Successful in 7s
Deploy Promiscuity Auth API / deploy (push) Successful in 47s
Deploy Promiscuity Character API / deploy (push) Successful in 46s
Deploy Promiscuity Inventory API / deploy (push) Successful in 58s
Deploy Promiscuity Locations API / deploy (push) Successful in 44s
k8s smoke test / test (push) Successful in 7s
This commit is contained in:
@@ -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<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;
|
||||
private readonly HashSet<string> _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<InventoryItem>("InventoryItems");
|
||||
_definitions = db.GetCollection<ItemDefinition>("ItemDefinitions");
|
||||
_characters = db.GetCollection<CharacterOwnerDocument>("Characters");
|
||||
_locations = db.GetCollection<LocationOwnerDocument>("Locations");
|
||||
|
||||
@@ -84,30 +85,92 @@ public class InventoryStore
|
||||
.ThenBy(i => i.ItemKey)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<List<InventoryItem>> 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<List<ItemDefinition>> ListItemDefinitionsAsync() =>
|
||||
_definitions.Find(Builders<ItemDefinition>.Filter.Empty).SortBy(d => d.ItemKey).ToListAsync();
|
||||
|
||||
await InsertItemAsync(new InventoryItem
|
||||
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<List<InventoryItem>> 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<InventoryItem>(
|
||||
Builders<InventoryItem>.IndexKeys.Ascending(i => i.Id),
|
||||
new CreateIndexOptions { Unique = true, Name = ItemIdIndexName }));
|
||||
|
||||
_items.Indexes.CreateOne(new CreateIndexModel<InventoryItem>(
|
||||
Builders<InventoryItem>.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<InventoryItem>.Filter.Ne(i => i.Slot, null)
|
||||
PartialFilterExpression = new BsonDocument("slot", new BsonDocument("$exists", true))
|
||||
}));
|
||||
|
||||
_items.Indexes.CreateOne(new CreateIndexModel<InventoryItem>(
|
||||
@@ -475,8 +561,12 @@ public class InventoryStore
|
||||
{
|
||||
Unique = true,
|
||||
Name = EquippedSlotIndexName,
|
||||
PartialFilterExpression = Builders<InventoryItem>.Filter.Ne(i => i.EquippedSlot, null)
|
||||
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) =>
|
||||
@@ -491,6 +581,7 @@ public class InventoryStore
|
||||
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]
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user