Zeeshaun 9a7d6544ef
All checks were successful
Deploy Promiscuity Auth API / deploy (push) Successful in 46s
Deploy Promiscuity Character API / deploy (push) Successful in 46s
Deploy Promiscuity Locations API / deploy (push) Successful in 46s
k8s smoke test / test (push) Successful in 7s
Adding inventory microservice
2026-03-15 10:21:49 -05:00

510 lines
21 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 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 readonly IMongoCollection<InventoryItem> _items;
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)
{
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");
_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<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);
}
await InsertItemAsync(new InventoryItem
{
ItemKey = normalizedKey,
Quantity = req.Quantity,
OwnerType = owner.OwnerType,
OwnerId = owner.OwnerId,
OwnerUserId = owner.OwnerUserId,
Slot = targetSlot
});
return await GetByOwnerAsync(owner.OwnerType, owner.OwnerId);
}
var nextPreferredSlot = req.PreferredSlot;
for (var index = 0; index < req.Quantity; index += 1)
{
var slot = nextPreferredSlot ?? await FindFirstOpenSlotAsync(owner.OwnerType, owner.OwnerId);
await InsertItemAsync(new InventoryItem
{
ItemKey = normalizedKey,
Quantity = 1,
OwnerType = owner.OwnerType,
OwnerId = owner.OwnerId,
OwnerUserId = owner.OwnerUserId,
Slot = slot
});
nextPreferredSlot = null;
}
return await GetByOwnerAsync(owner.OwnerType, owner.OwnerId);
}
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 existing = await FindItemBySlotAsync(owner.OwnerType, owner.OwnerId, req.ToSlot);
if (existing is not null && existing.Id != item.Id)
{
if (!CanMerge(item, existing))
return new InventoryMutationResult { Status = InventoryMutationStatus.Conflict };
existing.Quantity += quantity;
existing.UpdatedUtc = DateTime.UtcNow;
await ReplaceItemAsync(existing);
if (quantity == item.Quantity)
await DeleteItemAsync(item.Id);
else
{
item.Quantity -= quantity;
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)
{
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 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))
{
await session.AbortTransactionAsync();
return new InventoryMutationResult { Status = InventoryMutationStatus.Conflict };
}
if (target is not null)
{
target.Quantity += quantity;
target.UpdatedUtc = DateTime.UtcNow;
await ReplaceItemAsync(target, session);
if (quantity == item.Quantity)
await DeleteItemAsync(item.Id, session);
else
{
item.Quantity -= quantity;
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<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 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);
var existing = await FindItemBySlotAsync(item.OwnerType, item.OwnerId, slot);
if (existing is not null && existing.Id != item.Id)
return new InventoryMutationResult { Status = InventoryMutationStatus.Conflict };
item.EquippedSlot = null;
item.Slot = slot;
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 slot = 0;
while (usedSlots.Contains(slot))
slot += 1;
return slot;
}
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 = item.ItemKey.Trim();
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 bool IsStackable(string itemKey) => _stackableItemKeys.Contains(itemKey.Trim().ToLowerInvariant());
private bool CanMerge(InventoryItem source, InventoryItem target) =>
source.ItemKey == target.ItemKey &&
source.EquippedSlot is null &&
target.EquippedSlot is null &&
IsStackable(source.ItemKey);
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 }));
_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 = Builders<InventoryItem>.Filter.Ne(i => i.Slot, null)
}));
_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 = Builders<InventoryItem>.Filter.Ne(i => i.EquippedSlot, null)
}));
}
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();
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;
}
private class LocationOwnerDocument
{
[MongoDB.Bson.Serialization.Attributes.BsonId]
[MongoDB.Bson.Serialization.Attributes.BsonRepresentation(MongoDB.Bson.BsonType.ObjectId)]
public string? Id { get; set; }
}
}