Adding inventory stacking and overflow to gather mechanic
Deploy Promiscuity Auth API / deploy (push) Successful in 47s
Deploy Promiscuity Character API / deploy (push) Successful in 44s
Deploy Promiscuity Inventory API / deploy (push) Successful in 58s
Deploy Promiscuity Locations API / deploy (push) Successful in 58s
k8s smoke test / test (push) Successful in 9s

This commit is contained in:
2026-03-19 17:24:10 -05:00
parent 69ff204c5d
commit b8ce13f1d2
6 changed files with 344 additions and 98 deletions
@@ -124,12 +124,15 @@ public class InventoryController : ControllerBase
if (definition is null)
return BadRequest("Unknown itemKey");
var items = await _inventory.GrantAsync(access, req, definition);
var grant = await _inventory.GrantAsync(access, req, definition);
return Ok(new InventoryOwnerResponse
{
OwnerType = access.OwnerType,
OwnerId = access.OwnerId,
Items = items.Select(InventoryItemResponse.FromModel).ToList()
RequestedQuantity = grant.RequestedQuantity,
GrantedQuantity = grant.GrantedQuantity,
OverflowQuantity = grant.OverflowQuantity,
Items = grant.Items.Select(InventoryItemResponse.FromModel).ToList()
});
}
@@ -6,5 +6,11 @@ public class InventoryOwnerResponse
public string OwnerId { get; set; } = string.Empty;
public int RequestedQuantity { get; set; }
public int GrantedQuantity { get; set; }
public int OverflowQuantity { get; set; }
public List<InventoryItemResponse> Items { get; set; } = [];
}
@@ -8,6 +8,7 @@ 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";
@@ -20,6 +21,8 @@ public class InventoryStore
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";
@@ -131,53 +134,70 @@ public class InventoryStore
return existing;
}
public async Task<List<InventoryItem>> GrantAsync(OwnerAccessResult owner, GrantInventoryItemRequest req, ItemDefinition definition)
public async Task<GrantResult> GrantAsync(OwnerAccessResult owner, GrantInventoryItemRequest req, ItemDefinition definition)
{
var normalizedKey = NormalizeItemKey(req.ItemKey);
var remaining = req.Quantity;
if (definition.Stackable)
{
var remaining = req.Quantity;
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);
var existing = await FindStackAsync(owner.OwnerType, owner.OwnerId, normalizedKey, slot);
if (existing is not null)
if (slot is null)
break;
var stackQuantity = Math.Min(remaining, definition.MaxStackSize);
await InsertItemAsync(new InventoryItem
{
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;
}
ItemKey = normalizedKey,
Quantity = stackQuantity,
OwnerType = owner.OwnerType,
OwnerId = owner.OwnerId,
OwnerUserId = owner.OwnerUserId,
Slot = slot.Value
});
remaining -= stackQuantity;
targetSlot = null;
}
return await GetByOwnerAsync(owner.OwnerType, owner.OwnerId);
var stackItems = await GetByOwnerAsync(owner.OwnerType, owner.OwnerId);
return new GrantResult(stackItems, req.Quantity, req.Quantity - remaining, remaining);
}
var nextPreferredSlot = req.PreferredSlot;
for (var index = 0; index < req.Quantity; index += 1)
while (remaining > 0)
{
var slot = nextPreferredSlot ?? await FindFirstOpenSlotAsync(owner.OwnerType, owner.OwnerId);
if (slot is null)
break;
await InsertItemAsync(new InventoryItem
{
ItemKey = normalizedKey,
@@ -185,11 +205,13 @@ public class InventoryStore
OwnerType = owner.OwnerType,
OwnerId = owner.OwnerId,
OwnerUserId = owner.OwnerUserId,
Slot = slot
Slot = slot.Value
});
remaining -= 1;
nextPreferredSlot = null;
}
return await GetByOwnerAsync(owner.OwnerType, owner.OwnerId);
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)
@@ -298,7 +320,12 @@ public class InventoryStore
}
var toSlot = req.ToSlot ?? await FindFirstOpenSlotAsync(toOwner.OwnerType, toOwner.OwnerId, session);
var target = await FindItemBySlotAsync(toOwner.OwnerType, toOwner.OwnerId, toSlot, 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();
@@ -451,12 +478,14 @@ public class InventoryStore
return new InventoryMutationResult { Status = InventoryMutationStatus.Invalid };
var slot = preferredSlot ?? await FindFirstOpenSlotAsync(item.OwnerType, item.OwnerId);
var existing = await FindItemBySlotAsync(item.OwnerType, item.OwnerId, slot);
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;
item.Slot = slot.Value;
item.UpdatedUtc = DateTime.UtcNow;
await ReplaceItemAsync(item);
@@ -480,16 +509,19 @@ public class InventoryStore
};
}
private async Task<int> FindFirstOpenSlotAsync(string ownerType, string ownerId, IClientSessionHandle? session = 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;
}
@@ -540,6 +572,11 @@ public class InventoryStore
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>(