Inventory enhancements
All checks were successful
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
All checks were successful
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:
parent
5287ecd56f
commit
a2a4d48de5
@ -17,6 +17,63 @@ public class InventoryController : ControllerBase
|
|||||||
_inventory = inventory;
|
_inventory = inventory;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("item-definitions")]
|
||||||
|
[Authorize(Roles = "USER,SUPER")]
|
||||||
|
public async Task<IActionResult> ListItemDefinitions()
|
||||||
|
{
|
||||||
|
var definitions = await _inventory.ListItemDefinitionsAsync();
|
||||||
|
return Ok(definitions.Select(ItemDefinitionResponse.FromModel).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("item-definitions/{itemKey}")]
|
||||||
|
[Authorize(Roles = "USER,SUPER")]
|
||||||
|
public async Task<IActionResult> GetItemDefinition(string itemKey)
|
||||||
|
{
|
||||||
|
var definition = await _inventory.GetItemDefinitionAsync(itemKey);
|
||||||
|
if (definition is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
return Ok(ItemDefinitionResponse.FromModel(definition));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("item-definitions")]
|
||||||
|
[Authorize(Roles = "SUPER")]
|
||||||
|
public async Task<IActionResult> CreateItemDefinition([FromBody] CreateItemDefinitionRequest req)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(req.ItemKey))
|
||||||
|
return BadRequest("itemKey required");
|
||||||
|
if (string.IsNullOrWhiteSpace(req.DisplayName))
|
||||||
|
return BadRequest("displayName required");
|
||||||
|
if (req.MaxStackSize <= 0)
|
||||||
|
return BadRequest("maxStackSize must be greater than 0");
|
||||||
|
if (!req.Stackable && req.MaxStackSize != 1)
|
||||||
|
return BadRequest("Non-stackable items must have maxStackSize of 1");
|
||||||
|
|
||||||
|
var created = await _inventory.CreateItemDefinitionAsync(req);
|
||||||
|
if (created is null)
|
||||||
|
return Conflict("Item definition already exists");
|
||||||
|
|
||||||
|
return Ok(ItemDefinitionResponse.FromModel(created));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("item-definitions/{itemKey}")]
|
||||||
|
[Authorize(Roles = "SUPER")]
|
||||||
|
public async Task<IActionResult> UpdateItemDefinition(string itemKey, [FromBody] UpdateItemDefinitionRequest req)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(req.DisplayName))
|
||||||
|
return BadRequest("displayName required");
|
||||||
|
if (req.MaxStackSize <= 0)
|
||||||
|
return BadRequest("maxStackSize must be greater than 0");
|
||||||
|
if (!req.Stackable && req.MaxStackSize != 1)
|
||||||
|
return BadRequest("Non-stackable items must have maxStackSize of 1");
|
||||||
|
|
||||||
|
var updated = await _inventory.UpdateItemDefinitionAsync(itemKey, req);
|
||||||
|
if (updated is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
return Ok(ItemDefinitionResponse.FromModel(updated));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("by-owner/{ownerType}/{ownerId}")]
|
[HttpGet("by-owner/{ownerType}/{ownerId}")]
|
||||||
[Authorize(Roles = "USER,SUPER")]
|
[Authorize(Roles = "USER,SUPER")]
|
||||||
public async Task<IActionResult> GetByOwner(string ownerType, string ownerId)
|
public async Task<IActionResult> GetByOwner(string ownerType, string ownerId)
|
||||||
@ -63,7 +120,11 @@ public class InventoryController : ControllerBase
|
|||||||
if (!access.IsAuthorized)
|
if (!access.IsAuthorized)
|
||||||
return Forbid();
|
return Forbid();
|
||||||
|
|
||||||
var items = await _inventory.GrantAsync(access, req);
|
var definition = await _inventory.GetItemDefinitionAsync(req.ItemKey);
|
||||||
|
if (definition is null)
|
||||||
|
return BadRequest("Unknown itemKey");
|
||||||
|
|
||||||
|
var items = await _inventory.GrantAsync(access, req, definition);
|
||||||
return Ok(new InventoryOwnerResponse
|
return Ok(new InventoryOwnerResponse
|
||||||
{
|
{
|
||||||
OwnerType = access.OwnerType,
|
OwnerType = access.OwnerType,
|
||||||
|
|||||||
@ -3,6 +3,29 @@
|
|||||||
This service stores one MongoDB document per inventory item record.
|
This service stores one MongoDB document per inventory item record.
|
||||||
|
|
||||||
Inbound JSON documents
|
Inbound JSON documents
|
||||||
|
- CreateItemDefinitionRequest (`POST /api/inventory/item-definitions`)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"itemKey": "wood",
|
||||||
|
"displayName": "Wood",
|
||||||
|
"stackable": true,
|
||||||
|
"maxStackSize": 20,
|
||||||
|
"category": "resource",
|
||||||
|
"equipSlot": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- UpdateItemDefinitionRequest (`PUT /api/inventory/item-definitions/{itemKey}`)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"displayName": "Wood",
|
||||||
|
"stackable": true,
|
||||||
|
"maxStackSize": 20,
|
||||||
|
"category": "resource",
|
||||||
|
"equipSlot": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
- GrantInventoryItemRequest (`POST /api/inventory/by-owner/{ownerType}/{ownerId}/grant`)
|
- GrantInventoryItemRequest (`POST /api/inventory/by-owner/{ownerType}/{ownerId}/grant`)
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@ -63,6 +86,20 @@ Inbound JSON documents
|
|||||||
Only valid for items currently equipped by a character.
|
Only valid for items currently equipped by a character.
|
||||||
|
|
||||||
Stored documents (MongoDB)
|
Stored documents (MongoDB)
|
||||||
|
- ItemDefinition
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"itemKey": "wood",
|
||||||
|
"displayName": "Wood",
|
||||||
|
"stackable": true,
|
||||||
|
"maxStackSize": 20,
|
||||||
|
"category": "resource",
|
||||||
|
"equipSlot": null,
|
||||||
|
"createdUtc": "string (ISO-8601 datetime)",
|
||||||
|
"updatedUtc": "string (ISO-8601 datetime)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
- InventoryItem
|
- InventoryItem
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@ -112,6 +149,19 @@ Location stack example:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Outbound JSON documents
|
Outbound JSON documents
|
||||||
|
- ItemDefinitionResponse
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"itemKey": "wood",
|
||||||
|
"displayName": "Wood",
|
||||||
|
"stackable": true,
|
||||||
|
"maxStackSize": 20,
|
||||||
|
"category": "resource",
|
||||||
|
"equipSlot": null,
|
||||||
|
"updatedUtc": "string (ISO-8601 datetime)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
- InventoryItemResponse
|
- InventoryItemResponse
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@ -160,10 +210,12 @@ Outbound JSON documents
|
|||||||
```
|
```
|
||||||
|
|
||||||
Validation rules
|
Validation rules
|
||||||
|
- `itemKey` must map to an existing item definition before item instances can be created
|
||||||
- `ownerType` must be a supported container type
|
- `ownerType` must be a supported container type
|
||||||
- `ownerId` must map to an existing owning entity where applicable
|
- `ownerId` must map to an existing owning entity where applicable
|
||||||
- non-`SUPER` callers may only access owned character items unless explicit gameplay rules allow a world container read/write
|
- non-`SUPER` callers may only access owned character items unless explicit gameplay rules allow a world container read/write
|
||||||
- `quantity` must be greater than `0`
|
- `quantity` must be greater than `0`
|
||||||
|
- non-stackable item definitions must have `maxStackSize = 1`
|
||||||
- non-stackable items must have `quantity = 1`
|
- non-stackable items must have `quantity = 1`
|
||||||
- equipped items must have `slot = null`
|
- equipped items must have `slot = null`
|
||||||
- unequipped bag items must have `equippedSlot = null`
|
- unequipped bag items must have `equippedSlot = null`
|
||||||
@ -172,11 +224,11 @@ Validation rules
|
|||||||
- equipment occupancy must be unique for `(ownerType, ownerId, equippedSlot)` where `equippedSlot != null`
|
- equipment occupancy must be unique for `(ownerType, ownerId, equippedSlot)` where `equippedSlot != null`
|
||||||
|
|
||||||
Recommended indexes
|
Recommended indexes
|
||||||
- unique on `id`
|
- unique on `itemKey` for item definitions
|
||||||
- index on `(ownerType, ownerId)`
|
- index on `(ownerType, ownerId)`
|
||||||
- unique on `(ownerType, ownerId, slot)` for bag slots
|
- unique on `(ownerType, ownerId, slot)` for bag slots
|
||||||
- unique on `(ownerType, ownerId, equippedSlot)` for equipped slots
|
- unique on `(ownerType, ownerId, equippedSlot)` for equipped slots
|
||||||
- index on `itemKey`
|
- index on `itemKey` for inventory items
|
||||||
|
|
||||||
Behavior rules
|
Behavior rules
|
||||||
- moving a full non-stackable item should update its owner and slot in place
|
- moving a full non-stackable item should update its owner and slot in place
|
||||||
@ -184,3 +236,4 @@ Behavior rules
|
|||||||
- moving into a compatible stack should merge quantities and delete or reduce the source record
|
- moving into a compatible stack should merge quantities and delete or reduce the source record
|
||||||
- cross-owner transfer should be transactional when it mutates multiple records
|
- cross-owner transfer should be transactional when it mutates multiple records
|
||||||
- auctions should reference `itemId` values directly instead of copying item state into the auction document
|
- auctions should reference `itemId` values directly instead of copying item state into the auction document
|
||||||
|
- the service does not auto-seed item definitions; `ItemDefinitions` must be populated explicitly
|
||||||
|
|||||||
@ -0,0 +1,16 @@
|
|||||||
|
namespace InventoryApi.Models;
|
||||||
|
|
||||||
|
public class CreateItemDefinitionRequest
|
||||||
|
{
|
||||||
|
public string ItemKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public bool Stackable { get; set; }
|
||||||
|
|
||||||
|
public int MaxStackSize { get; set; } = 1;
|
||||||
|
|
||||||
|
public string Category { get; set; } = "misc";
|
||||||
|
|
||||||
|
public string? EquipSlot { get; set; }
|
||||||
|
}
|
||||||
33
microservices/InventoryApi/Models/ItemDefinition.cs
Normal file
33
microservices/InventoryApi/Models/ItemDefinition.cs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
using MongoDB.Bson.Serialization.Attributes;
|
||||||
|
|
||||||
|
namespace InventoryApi.Models;
|
||||||
|
|
||||||
|
[BsonIgnoreExtraElements]
|
||||||
|
public class ItemDefinition
|
||||||
|
{
|
||||||
|
[BsonId]
|
||||||
|
[BsonElement("itemKey")]
|
||||||
|
public string ItemKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("displayName")]
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("stackable")]
|
||||||
|
public bool Stackable { get; set; }
|
||||||
|
|
||||||
|
[BsonElement("maxStackSize")]
|
||||||
|
public int MaxStackSize { get; set; } = 1;
|
||||||
|
|
||||||
|
[BsonElement("category")]
|
||||||
|
public string Category { get; set; } = "misc";
|
||||||
|
|
||||||
|
[BsonElement("equipSlot")]
|
||||||
|
[BsonIgnoreIfNull]
|
||||||
|
public string? EquipSlot { get; set; }
|
||||||
|
|
||||||
|
[BsonElement("createdUtc")]
|
||||||
|
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
[BsonElement("updatedUtc")]
|
||||||
|
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
29
microservices/InventoryApi/Models/ItemDefinitionResponse.cs
Normal file
29
microservices/InventoryApi/Models/ItemDefinitionResponse.cs
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
namespace InventoryApi.Models;
|
||||||
|
|
||||||
|
public class ItemDefinitionResponse
|
||||||
|
{
|
||||||
|
public string ItemKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public bool Stackable { get; set; }
|
||||||
|
|
||||||
|
public int MaxStackSize { get; set; }
|
||||||
|
|
||||||
|
public string Category { get; set; } = "misc";
|
||||||
|
|
||||||
|
public string? EquipSlot { get; set; }
|
||||||
|
|
||||||
|
public DateTime UpdatedUtc { get; set; }
|
||||||
|
|
||||||
|
public static ItemDefinitionResponse FromModel(ItemDefinition definition) => new()
|
||||||
|
{
|
||||||
|
ItemKey = definition.ItemKey,
|
||||||
|
DisplayName = definition.DisplayName,
|
||||||
|
Stackable = definition.Stackable,
|
||||||
|
MaxStackSize = definition.MaxStackSize,
|
||||||
|
Category = definition.Category,
|
||||||
|
EquipSlot = definition.EquipSlot,
|
||||||
|
UpdatedUtc = definition.UpdatedUtc
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
namespace InventoryApi.Models;
|
||||||
|
|
||||||
|
public class UpdateItemDefinitionRequest
|
||||||
|
{
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public bool Stackable { get; set; }
|
||||||
|
|
||||||
|
public int MaxStackSize { get; set; } = 1;
|
||||||
|
|
||||||
|
public string Category { get; set; } = "misc";
|
||||||
|
|
||||||
|
public string? EquipSlot { get; set; }
|
||||||
|
}
|
||||||
@ -104,6 +104,14 @@ Optional future fields:
|
|||||||
- slot occupancy must be unique per `(ownerType, ownerId, slot)`
|
- slot occupancy must be unique per `(ownerType, ownerId, slot)`
|
||||||
- all mutating endpoints should be idempotent where practical
|
- all mutating endpoints should be idempotent where practical
|
||||||
|
|
||||||
|
## Definition source
|
||||||
|
Item behavior is fully data-driven through the `ItemDefinitions` collection inside `InventoryApi`.
|
||||||
|
|
||||||
|
That means:
|
||||||
|
- no hardcoded stack-size rules
|
||||||
|
- no automatic item-definition seeding
|
||||||
|
- item instances can only be created for `itemKey` values that already exist in `ItemDefinitions`
|
||||||
|
|
||||||
## Client shape
|
## Client shape
|
||||||
The Godot client should fetch all items for the currently relevant owner and group them into a bag view locally.
|
The Godot client should fetch all items for the currently relevant owner and group them into a bag view locally.
|
||||||
|
|
||||||
|
|||||||
@ -8,17 +8,17 @@ public class InventoryStore
|
|||||||
{
|
{
|
||||||
private const string CharacterOwnerType = "character";
|
private const string CharacterOwnerType = "character";
|
||||||
private const string LocationOwnerType = "location";
|
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 OwnerIndexName = "owner_type_1_owner_id_1";
|
||||||
private const string SlotIndexName = "owner_type_1_owner_id_1_slot_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 EquippedSlotIndexName = "owner_type_1_owner_id_1_equipped_slot_1";
|
||||||
|
private const string DefinitionCategoryIndexName = "category_1";
|
||||||
|
|
||||||
private readonly IMongoCollection<InventoryItem> _items;
|
private readonly IMongoCollection<InventoryItem> _items;
|
||||||
|
private readonly IMongoCollection<ItemDefinition> _definitions;
|
||||||
private readonly IMongoCollection<CharacterOwnerDocument> _characters;
|
private readonly IMongoCollection<CharacterOwnerDocument> _characters;
|
||||||
private readonly IMongoCollection<LocationOwnerDocument> _locations;
|
private readonly IMongoCollection<LocationOwnerDocument> _locations;
|
||||||
private readonly IMongoClient _client;
|
private readonly IMongoClient _client;
|
||||||
private readonly string _dbName;
|
private readonly string _dbName;
|
||||||
private readonly HashSet<string> _stackableItemKeys = ["wood", "stone", "small_health_potion"];
|
|
||||||
|
|
||||||
public InventoryStore(IConfiguration cfg)
|
public InventoryStore(IConfiguration cfg)
|
||||||
{
|
{
|
||||||
@ -27,6 +27,7 @@ public class InventoryStore
|
|||||||
_client = new MongoClient(cs);
|
_client = new MongoClient(cs);
|
||||||
var db = _client.GetDatabase(_dbName);
|
var db = _client.GetDatabase(_dbName);
|
||||||
_items = db.GetCollection<InventoryItem>("InventoryItems");
|
_items = db.GetCollection<InventoryItem>("InventoryItems");
|
||||||
|
_definitions = db.GetCollection<ItemDefinition>("ItemDefinitions");
|
||||||
_characters = db.GetCollection<CharacterOwnerDocument>("Characters");
|
_characters = db.GetCollection<CharacterOwnerDocument>("Characters");
|
||||||
_locations = db.GetCollection<LocationOwnerDocument>("Locations");
|
_locations = db.GetCollection<LocationOwnerDocument>("Locations");
|
||||||
|
|
||||||
@ -84,30 +85,92 @@ public class InventoryStore
|
|||||||
.ThenBy(i => i.ItemKey)
|
.ThenBy(i => i.ItemKey)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
public async Task<List<InventoryItem>> GrantAsync(OwnerAccessResult owner, GrantInventoryItemRequest req)
|
public Task<List<ItemDefinition>> ListItemDefinitionsAsync() =>
|
||||||
{
|
_definitions.Find(Builders<ItemDefinition>.Filter.Empty).SortBy(d => d.ItemKey).ToListAsync();
|
||||||
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
|
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,
|
var slot = targetSlot ?? await FindFirstOpenSlotAsync(owner.OwnerType, owner.OwnerId);
|
||||||
Quantity = req.Quantity,
|
var existing = await FindStackAsync(owner.OwnerType, owner.OwnerId, normalizedKey, slot);
|
||||||
OwnerType = owner.OwnerType,
|
if (existing is not null)
|
||||||
OwnerId = owner.OwnerId,
|
{
|
||||||
OwnerUserId = owner.OwnerUserId,
|
var availableSpace = definition.MaxStackSize - existing.Quantity;
|
||||||
Slot = targetSlot
|
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);
|
return await GetByOwnerAsync(owner.OwnerType, owner.OwnerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,21 +204,29 @@ public class InventoryStore
|
|||||||
if (quantity <= 0 || quantity > item.Quantity)
|
if (quantity <= 0 || quantity > item.Quantity)
|
||||||
return new InventoryMutationResult { Status = InventoryMutationStatus.Invalid };
|
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);
|
var existing = await FindItemBySlotAsync(owner.OwnerType, owner.OwnerId, req.ToSlot);
|
||||||
if (existing is not null && existing.Id != item.Id)
|
if (existing is not null && existing.Id != item.Id)
|
||||||
{
|
{
|
||||||
if (!CanMerge(item, existing))
|
if (!CanMerge(item, existing, definition))
|
||||||
return new InventoryMutationResult { Status = InventoryMutationStatus.Conflict };
|
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;
|
existing.UpdatedUtc = DateTime.UtcNow;
|
||||||
await ReplaceItemAsync(existing);
|
await ReplaceItemAsync(existing);
|
||||||
|
|
||||||
if (quantity == item.Quantity)
|
if (transferable == item.Quantity)
|
||||||
await DeleteItemAsync(item.Id);
|
await DeleteItemAsync(item.Id);
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
item.Quantity -= quantity;
|
item.Quantity -= transferable;
|
||||||
item.UpdatedUtc = DateTime.UtcNow;
|
item.UpdatedUtc = DateTime.UtcNow;
|
||||||
await ReplaceItemAsync(item);
|
await ReplaceItemAsync(item);
|
||||||
}
|
}
|
||||||
@ -219,9 +290,16 @@ public class InventoryStore
|
|||||||
return new InventoryMutationResult { Status = InventoryMutationStatus.Invalid };
|
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 toSlot = req.ToSlot ?? await FindFirstOpenSlotAsync(toOwner.OwnerType, toOwner.OwnerId, session);
|
||||||
var target = await FindItemBySlotAsync(toOwner.OwnerType, toOwner.OwnerId, toSlot, 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();
|
await session.AbortTransactionAsync();
|
||||||
return new InventoryMutationResult { Status = InventoryMutationStatus.Conflict };
|
return new InventoryMutationResult { Status = InventoryMutationStatus.Conflict };
|
||||||
@ -229,15 +307,22 @@ public class InventoryStore
|
|||||||
|
|
||||||
if (target is not null)
|
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;
|
target.UpdatedUtc = DateTime.UtcNow;
|
||||||
await ReplaceItemAsync(target, session);
|
await ReplaceItemAsync(target, session);
|
||||||
|
|
||||||
if (quantity == item.Quantity)
|
if (transferable == item.Quantity)
|
||||||
await DeleteItemAsync(item.Id, session);
|
await DeleteItemAsync(item.Id, session);
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
item.Quantity -= quantity;
|
item.Quantity -= transferable;
|
||||||
item.UpdatedUtc = DateTime.UtcNow;
|
item.UpdatedUtc = DateTime.UtcNow;
|
||||||
await ReplaceItemAsync(item, session);
|
await ReplaceItemAsync(item, session);
|
||||||
}
|
}
|
||||||
@ -326,6 +411,11 @@ public class InventoryStore
|
|||||||
return new InventoryMutationResult { Status = InventoryMutationStatus.ItemNotFound };
|
return new InventoryMutationResult { Status = InventoryMutationStatus.ItemNotFound };
|
||||||
if (item.OwnerType != CharacterOwnerType || item.OwnerId != ownerId || item.Slot is null)
|
if (item.OwnerType != CharacterOwnerType || item.OwnerId != ownerId || item.Slot is null)
|
||||||
return new InventoryMutationResult { Status = InventoryMutationStatus.Invalid };
|
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 =>
|
var equipped = await _items.Find(i =>
|
||||||
i.OwnerType == CharacterOwnerType &&
|
i.OwnerType == CharacterOwnerType &&
|
||||||
@ -415,7 +505,7 @@ public class InventoryStore
|
|||||||
|
|
||||||
private async Task InsertItemAsync(InventoryItem item, IClientSessionHandle? session = null)
|
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.OwnerType = item.OwnerType.Trim().ToLowerInvariant();
|
||||||
item.CreatedUtc = DateTime.UtcNow;
|
item.CreatedUtc = DateTime.UtcNow;
|
||||||
item.UpdatedUtc = item.CreatedUtc;
|
item.UpdatedUtc = item.CreatedUtc;
|
||||||
@ -442,20 +532,16 @@ public class InventoryStore
|
|||||||
: _items.DeleteOneAsync(session, i => i.Id == itemId);
|
: _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.ItemKey == target.ItemKey &&
|
||||||
source.EquippedSlot is null &&
|
source.EquippedSlot is null &&
|
||||||
target.EquippedSlot is null &&
|
target.EquippedSlot is null &&
|
||||||
IsStackable(source.ItemKey);
|
definition.Stackable;
|
||||||
|
|
||||||
private void EnsureIndexes()
|
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>(
|
_items.Indexes.CreateOne(new CreateIndexModel<InventoryItem>(
|
||||||
Builders<InventoryItem>.IndexKeys.Ascending(i => i.OwnerType).Ascending(i => i.OwnerId),
|
Builders<InventoryItem>.IndexKeys.Ascending(i => i.OwnerType).Ascending(i => i.OwnerId),
|
||||||
new CreateIndexOptions { Name = OwnerIndexName }));
|
new CreateIndexOptions { Name = OwnerIndexName }));
|
||||||
@ -466,7 +552,7 @@ public class InventoryStore
|
|||||||
{
|
{
|
||||||
Unique = true,
|
Unique = true,
|
||||||
Name = SlotIndexName,
|
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>(
|
_items.Indexes.CreateOne(new CreateIndexModel<InventoryItem>(
|
||||||
@ -475,8 +561,12 @@ public class InventoryStore
|
|||||||
{
|
{
|
||||||
Unique = true,
|
Unique = true,
|
||||||
Name = EquippedSlotIndexName,
|
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) =>
|
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) =>
|
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();
|
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
|
private class CharacterOwnerDocument
|
||||||
{
|
{
|
||||||
[MongoDB.Bson.Serialization.Attributes.BsonId]
|
[MongoDB.Bson.Serialization.Attributes.BsonId]
|
||||||
@ -500,6 +591,7 @@ public class InventoryStore
|
|||||||
public string OwnerUserId { get; set; } = string.Empty;
|
public string OwnerUserId { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[MongoDB.Bson.Serialization.Attributes.BsonIgnoreExtraElements]
|
||||||
private class LocationOwnerDocument
|
private class LocationOwnerDocument
|
||||||
{
|
{
|
||||||
[MongoDB.Bson.Serialization.Attributes.BsonId]
|
[MongoDB.Bson.Serialization.Attributes.BsonId]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user