From 9a7d6544ef33d7d1d5e13d59cb054f489083ca12 Mon Sep 17 00:00:00 2001 From: Zeeshaun Date: Sun, 15 Mar 2026 10:21:49 -0500 Subject: [PATCH] Adding inventory microservice --- game/scenes/Levels/location_level.gd | 2 +- .../Controllers/InventoryController.cs | 248 +++++++++ microservices/InventoryApi/DOCUMENTS.md | 186 +++++++ microservices/InventoryApi/Dockerfile | 19 + .../InventoryApi/InventoryApi.csproj | 16 + .../Models/ConsumeInventoryItemRequest.cs | 6 + .../Models/EquipInventoryItemRequest.cs | 8 + .../Models/GrantInventoryItemRequest.cs | 10 + .../InventoryApi/Models/InventoryItem.cs | 38 ++ .../Models/InventoryItemResponse.cs | 32 ++ .../Models/InventoryMutationResult.cs | 22 + .../Models/InventoryOwnerResponse.cs | 10 + .../Models/MoveInventoryItemRequest.cs | 10 + .../InventoryApi/Models/OwnerAccessResult.cs | 16 + .../Models/TransferInventoryItemRequest.cs | 18 + .../Models/TransferInventoryResponse.cs | 18 + .../Models/UnequipInventoryItemRequest.cs | 8 + microservices/InventoryApi/Program.cs | 106 ++++ .../Properties/launchSettings.json | 14 + microservices/InventoryApi/README.md | 116 ++++ .../InventoryApi/Services/InventoryStore.cs | 509 ++++++++++++++++++ .../InventoryApi/appsettings.Development.json | 8 + microservices/InventoryApi/appsettings.json | 7 + .../InventoryApi/k8s/deployment.yaml | 28 + microservices/InventoryApi/k8s/service.yaml | 15 + microservices/README.md | 2 + microservices/micro-services.sln | 18 +- 27 files changed, 1483 insertions(+), 7 deletions(-) create mode 100644 microservices/InventoryApi/Controllers/InventoryController.cs create mode 100644 microservices/InventoryApi/DOCUMENTS.md create mode 100644 microservices/InventoryApi/Dockerfile create mode 100644 microservices/InventoryApi/InventoryApi.csproj create mode 100644 microservices/InventoryApi/Models/ConsumeInventoryItemRequest.cs create mode 100644 microservices/InventoryApi/Models/EquipInventoryItemRequest.cs create mode 100644 microservices/InventoryApi/Models/GrantInventoryItemRequest.cs create mode 100644 microservices/InventoryApi/Models/InventoryItem.cs create mode 100644 microservices/InventoryApi/Models/InventoryItemResponse.cs create mode 100644 microservices/InventoryApi/Models/InventoryMutationResult.cs create mode 100644 microservices/InventoryApi/Models/InventoryOwnerResponse.cs create mode 100644 microservices/InventoryApi/Models/MoveInventoryItemRequest.cs create mode 100644 microservices/InventoryApi/Models/OwnerAccessResult.cs create mode 100644 microservices/InventoryApi/Models/TransferInventoryItemRequest.cs create mode 100644 microservices/InventoryApi/Models/TransferInventoryResponse.cs create mode 100644 microservices/InventoryApi/Models/UnequipInventoryItemRequest.cs create mode 100644 microservices/InventoryApi/Program.cs create mode 100644 microservices/InventoryApi/Properties/launchSettings.json create mode 100644 microservices/InventoryApi/README.md create mode 100644 microservices/InventoryApi/Services/InventoryStore.cs create mode 100644 microservices/InventoryApi/appsettings.Development.json create mode 100644 microservices/InventoryApi/appsettings.json create mode 100644 microservices/InventoryApi/k8s/deployment.yaml create mode 100644 microservices/InventoryApi/k8s/service.yaml diff --git a/game/scenes/Levels/location_level.gd b/game/scenes/Levels/location_level.gd index 7d4f7aa..48d01b2 100644 --- a/game/scenes/Levels/location_level.gd +++ b/game/scenes/Levels/location_level.gd @@ -2,7 +2,7 @@ extends Node3D const CHARACTER_API_URL := "https://pchar.ranaze.com/api/Characters" -@export var tile_size := 4.0 +@export var tile_size := 16.0 @export var block_height := 1.0 @export_range(1, 8, 1) var tile_radius := 3 @export var tracked_node_path: NodePath diff --git a/microservices/InventoryApi/Controllers/InventoryController.cs b/microservices/InventoryApi/Controllers/InventoryController.cs new file mode 100644 index 0000000..85c3c0b --- /dev/null +++ b/microservices/InventoryApi/Controllers/InventoryController.cs @@ -0,0 +1,248 @@ +using InventoryApi.Models; +using InventoryApi.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; + +namespace InventoryApi.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class InventoryController : ControllerBase +{ + private readonly InventoryStore _inventory; + + public InventoryController(InventoryStore inventory) + { + _inventory = inventory; + } + + [HttpGet("by-owner/{ownerType}/{ownerId}")] + [Authorize(Roles = "USER,SUPER")] + public async Task GetByOwner(string ownerType, string ownerId) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(userId)) + return Unauthorized(); + + var access = await _inventory.ResolveOwnerAsync(ownerType, ownerId, userId, User.IsInRole("SUPER")); + if (!access.IsSupported) + return BadRequest("Unsupported ownerType"); + if (!access.Exists) + return NotFound(); + if (!access.IsAuthorized) + return Forbid(); + + var items = await _inventory.GetByOwnerAsync(access.OwnerType, access.OwnerId); + return Ok(new InventoryOwnerResponse + { + OwnerType = access.OwnerType, + OwnerId = access.OwnerId, + Items = items.Select(InventoryItemResponse.FromModel).ToList() + }); + } + + [HttpPost("by-owner/{ownerType}/{ownerId}/grant")] + [Authorize(Roles = "USER,SUPER")] + public async Task Grant(string ownerType, string ownerId, [FromBody] GrantInventoryItemRequest req) + { + if (string.IsNullOrWhiteSpace(req.ItemKey)) + return BadRequest("itemKey required"); + if (req.Quantity <= 0) + return BadRequest("quantity must be greater than 0"); + + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(userId)) + return Unauthorized(); + + var access = await _inventory.ResolveOwnerAsync(ownerType, ownerId, userId, User.IsInRole("SUPER")); + if (!access.IsSupported) + return BadRequest("Unsupported ownerType"); + if (!access.Exists) + return NotFound(); + if (!access.IsAuthorized) + return Forbid(); + + var items = await _inventory.GrantAsync(access, req); + return Ok(new InventoryOwnerResponse + { + OwnerType = access.OwnerType, + OwnerId = access.OwnerId, + Items = items.Select(InventoryItemResponse.FromModel).ToList() + }); + } + + [HttpPost("by-owner/{ownerType}/{ownerId}/move")] + [Authorize(Roles = "USER,SUPER")] + public async Task Move(string ownerType, string ownerId, [FromBody] MoveInventoryItemRequest req) + { + if (string.IsNullOrWhiteSpace(req.ItemId)) + return BadRequest("itemId required"); + if (req.ToSlot < 0) + return BadRequest("toSlot must be >= 0"); + if (req.Quantity is <= 0) + return BadRequest("quantity must be greater than 0"); + + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(userId)) + return Unauthorized(); + + var access = await _inventory.ResolveOwnerAsync(ownerType, ownerId, userId, User.IsInRole("SUPER")); + if (!access.IsSupported) + return BadRequest("Unsupported ownerType"); + if (!access.Exists) + return NotFound(); + if (!access.IsAuthorized) + return Forbid(); + + var result = await _inventory.MoveAsync(access, req); + return result.Status switch + { + InventoryMutationStatus.ItemNotFound => NotFound(), + InventoryMutationStatus.Invalid => BadRequest("Invalid move"), + InventoryMutationStatus.Conflict => Conflict("Target slot is not available"), + _ => Ok(new InventoryOwnerResponse + { + OwnerType = access.OwnerType, + OwnerId = access.OwnerId, + Items = result.Items.Select(InventoryItemResponse.FromModel).ToList() + }) + }; + } + + [HttpPost("transfer")] + [Authorize(Roles = "USER,SUPER")] + public async Task Transfer([FromBody] TransferInventoryItemRequest req) + { + if (string.IsNullOrWhiteSpace(req.ItemId)) + return BadRequest("itemId required"); + if (string.IsNullOrWhiteSpace(req.FromOwnerType) || string.IsNullOrWhiteSpace(req.FromOwnerId)) + return BadRequest("from owner required"); + if (string.IsNullOrWhiteSpace(req.ToOwnerType) || string.IsNullOrWhiteSpace(req.ToOwnerId)) + return BadRequest("to owner required"); + if (req.ToSlot is < 0) + return BadRequest("toSlot must be >= 0"); + if (req.Quantity is <= 0) + return BadRequest("quantity must be greater than 0"); + + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(userId)) + return Unauthorized(); + + var fromAccess = await _inventory.ResolveOwnerAsync(req.FromOwnerType, req.FromOwnerId, userId, User.IsInRole("SUPER")); + if (!fromAccess.IsSupported) + return BadRequest("Unsupported fromOwnerType"); + if (!fromAccess.Exists) + return NotFound("Source owner not found"); + if (!fromAccess.IsAuthorized) + return Forbid(); + + var toAccess = await _inventory.ResolveOwnerAsync(req.ToOwnerType, req.ToOwnerId, userId, User.IsInRole("SUPER")); + if (!toAccess.IsSupported) + return BadRequest("Unsupported toOwnerType"); + if (!toAccess.Exists) + return NotFound("Target owner not found"); + if (!toAccess.IsAuthorized) + return Forbid(); + + var result = await _inventory.TransferAsync(fromAccess, toAccess, req); + return result.Status switch + { + InventoryMutationStatus.ItemNotFound => NotFound(), + InventoryMutationStatus.Invalid => BadRequest("Invalid transfer"), + InventoryMutationStatus.Conflict => Conflict("Target slot is not available"), + _ => Ok(new TransferInventoryResponse + { + MovedItemId = req.ItemId, + FromOwnerType = fromAccess.OwnerType, + FromOwnerId = fromAccess.OwnerId, + ToOwnerType = toAccess.OwnerType, + ToOwnerId = toAccess.OwnerId, + FromItems = result.Items.Select(InventoryItemResponse.FromModel).Where(x => x.OwnerType == fromAccess.OwnerType && x.OwnerId == fromAccess.OwnerId).ToList(), + ToItems = result.Items.Select(InventoryItemResponse.FromModel).Where(x => x.OwnerType == toAccess.OwnerType && x.OwnerId == toAccess.OwnerId).ToList() + }) + }; + } + + [HttpPost("items/{itemId}/consume")] + [Authorize(Roles = "USER,SUPER")] + public async Task Consume(string itemId, [FromBody] ConsumeInventoryItemRequest req) + { + if (req.Quantity <= 0) + return BadRequest("quantity must be greater than 0"); + + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(userId)) + return Unauthorized(); + + var result = await _inventory.ConsumeAsync(itemId, req.Quantity, userId, User.IsInRole("SUPER")); + return result.Status switch + { + InventoryMutationStatus.ItemNotFound => NotFound(), + InventoryMutationStatus.Invalid => BadRequest("Invalid consume request"), + InventoryMutationStatus.Conflict => Conflict(), + _ => Ok(new InventoryOwnerResponse + { + OwnerType = result.OwnerType, + OwnerId = result.OwnerId, + Items = result.Items.Select(InventoryItemResponse.FromModel).ToList() + }) + }; + } + + [HttpPost("items/{itemId}/equip")] + [Authorize(Roles = "USER,SUPER")] + public async Task Equip(string itemId, [FromBody] EquipInventoryItemRequest req) + { + if (string.IsNullOrWhiteSpace(req.OwnerId)) + return BadRequest("ownerId required"); + if (string.IsNullOrWhiteSpace(req.EquipmentSlot)) + return BadRequest("equipmentSlot required"); + + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(userId)) + return Unauthorized(); + + var result = await _inventory.EquipAsync(itemId, req.OwnerId, req.EquipmentSlot, userId, User.IsInRole("SUPER")); + return result.Status switch + { + InventoryMutationStatus.ItemNotFound => NotFound(), + InventoryMutationStatus.Invalid => BadRequest("Invalid equip request"), + InventoryMutationStatus.Conflict => Conflict("Equipment slot is not available"), + _ => Ok(new InventoryOwnerResponse + { + OwnerType = result.OwnerType, + OwnerId = result.OwnerId, + Items = result.Items.Select(InventoryItemResponse.FromModel).ToList() + }) + }; + } + + [HttpPost("items/{itemId}/unequip")] + [Authorize(Roles = "USER,SUPER")] + public async Task Unequip(string itemId, [FromBody] UnequipInventoryItemRequest req) + { + if (string.IsNullOrWhiteSpace(req.OwnerId)) + return BadRequest("ownerId required"); + if (req.PreferredSlot is < 0) + return BadRequest("preferredSlot must be >= 0"); + + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(userId)) + return Unauthorized(); + + var result = await _inventory.UnequipAsync(itemId, req.OwnerId, req.PreferredSlot, userId, User.IsInRole("SUPER")); + return result.Status switch + { + InventoryMutationStatus.ItemNotFound => NotFound(), + InventoryMutationStatus.Invalid => BadRequest("Invalid unequip request"), + InventoryMutationStatus.Conflict => Conflict("Inventory slot is not available"), + _ => Ok(new InventoryOwnerResponse + { + OwnerType = result.OwnerType, + OwnerId = result.OwnerId, + Items = result.Items.Select(InventoryItemResponse.FromModel).ToList() + }) + }; + } +} diff --git a/microservices/InventoryApi/DOCUMENTS.md b/microservices/InventoryApi/DOCUMENTS.md new file mode 100644 index 0000000..88d0c86 --- /dev/null +++ b/microservices/InventoryApi/DOCUMENTS.md @@ -0,0 +1,186 @@ +# InventoryApi document shapes + +This service stores one MongoDB document per inventory item record. + +Inbound JSON documents +- GrantInventoryItemRequest (`POST /api/inventory/by-owner/{ownerType}/{ownerId}/grant`) + ```json + { + "itemKey": "string", + "quantity": 1, + "preferredSlot": 0 + } + ``` + `preferredSlot` is optional. If omitted, the service finds a valid destination slot. + +- MoveInventoryItemRequest (`POST /api/inventory/by-owner/{ownerType}/{ownerId}/move`) + ```json + { + "itemId": "uuid-string", + "toSlot": 1, + "quantity": 1 + } + ``` + `quantity` is optional. If omitted, move the full stack. + +- TransferInventoryItemRequest (`POST /api/inventory/transfer`) + ```json + { + "itemId": "uuid-string", + "fromOwnerType": "character", + "fromOwnerId": "string", + "toOwnerType": "location", + "toOwnerId": "string", + "toSlot": 0, + "quantity": 1 + } + ``` + `toSlot` and `quantity` are optional. If omitted, the service finds a valid destination and transfers the full stack. + +- ConsumeInventoryItemRequest (`POST /api/inventory/items/{itemId}/consume`) + ```json + { + "quantity": 1 + } + ``` + +- EquipInventoryItemRequest (`POST /api/inventory/items/{itemId}/equip`) + ```json + { + "ownerId": "string", + "equipmentSlot": "weapon" + } + ``` + Only valid for items currently owned by a character inventory. + +- UnequipInventoryItemRequest (`POST /api/inventory/items/{itemId}/unequip`) + ```json + { + "ownerId": "string", + "preferredSlot": 0 + } + ``` + Only valid for items currently equipped by a character. + +Stored documents (MongoDB) +- InventoryItem + ```json + { + "id": "string (UUID)", + "itemKey": "wood", + "quantity": 12, + "ownerType": "character", + "ownerId": "string (ObjectId or stable external id)", + "ownerUserId": "string (ObjectId from auth token, null for public world owners)", + "slot": 0, + "equippedSlot": null, + "createdUtc": "string (ISO-8601 datetime)", + "updatedUtc": "string (ISO-8601 datetime)" + } + ``` + +Equipped character item example: +```json +{ + "id": "string (UUID)", + "itemKey": "pistol", + "quantity": 1, + "ownerType": "character", + "ownerId": "string (Character ObjectId)", + "ownerUserId": "string (ObjectId from auth token)", + "slot": null, + "equippedSlot": "weapon", + "createdUtc": "string (ISO-8601 datetime)", + "updatedUtc": "string (ISO-8601 datetime)" +} +``` + +Location stack example: +```json +{ + "id": "string (UUID)", + "itemKey": "wood", + "quantity": 12, + "ownerType": "location", + "ownerId": "string (Location ObjectId)", + "ownerUserId": null, + "slot": 3, + "equippedSlot": null, + "createdUtc": "string (ISO-8601 datetime)", + "updatedUtc": "string (ISO-8601 datetime)" +} +``` + +Outbound JSON documents +- InventoryItemResponse + ```json + { + "id": "string (UUID)", + "itemKey": "wood", + "quantity": 12, + "ownerType": "character", + "ownerId": "string", + "slot": 0, + "equippedSlot": null, + "updatedUtc": "string (ISO-8601 datetime)" + } + ``` + +- InventoryOwnerResponse (`GET /api/inventory/by-owner/{ownerType}/{ownerId}`) + ```json + { + "ownerType": "character", + "ownerId": "string", + "items": [ + { + "id": "string (UUID)", + "itemKey": "wood", + "quantity": 12, + "ownerType": "character", + "ownerId": "string", + "slot": 0, + "equippedSlot": null, + "updatedUtc": "string (ISO-8601 datetime)" + } + ] + } + ``` + +- TransferInventoryResponse (`POST /api/inventory/transfer`) + ```json + { + "movedItemId": "string (UUID)", + "fromOwnerType": "character", + "fromOwnerId": "string", + "toOwnerType": "location", + "toOwnerId": "string", + "fromItems": [], + "toItems": [] + } + ``` + +Validation rules +- `ownerType` must be a supported container type +- `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 +- `quantity` must be greater than `0` +- non-stackable items must have `quantity = 1` +- equipped items must have `slot = null` +- unequipped bag items must have `equippedSlot = null` +- an item must not have both `slot` and `equippedSlot` populated +- slot occupancy must be unique for `(ownerType, ownerId, slot)` where `slot != null` +- equipment occupancy must be unique for `(ownerType, ownerId, equippedSlot)` where `equippedSlot != null` + +Recommended indexes +- unique on `id` +- index on `(ownerType, ownerId)` +- unique on `(ownerType, ownerId, slot)` for bag slots +- unique on `(ownerType, ownerId, equippedSlot)` for equipped slots +- index on `itemKey` + +Behavior rules +- moving a full non-stackable item should update its owner and slot in place +- moving part of a stack should split the stack and create a new item record with a new UUID for the moved quantity +- 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 +- auctions should reference `itemId` values directly instead of copying item state into the auction document diff --git a/microservices/InventoryApi/Dockerfile b/microservices/InventoryApi/Dockerfile new file mode 100644 index 0000000..49087c6 --- /dev/null +++ b/microservices/InventoryApi/Dockerfile @@ -0,0 +1,19 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src + +COPY ["InventoryApi.csproj", "./"] +RUN dotnet restore "InventoryApi.csproj" + +COPY . . +RUN dotnet publish "InventoryApi.csproj" -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final +WORKDIR /app +COPY --from=build /app/publish . + +ENV ASPNETCORE_URLS=http://+:8080 \ + ASPNETCORE_ENVIRONMENT=Production + +EXPOSE 8080 + +ENTRYPOINT ["dotnet", "InventoryApi.dll"] diff --git a/microservices/InventoryApi/InventoryApi.csproj b/microservices/InventoryApi/InventoryApi.csproj new file mode 100644 index 0000000..0cb0950 --- /dev/null +++ b/microservices/InventoryApi/InventoryApi.csproj @@ -0,0 +1,16 @@ + + + + net8.0 + enable + enable + + + + + + + + + + diff --git a/microservices/InventoryApi/Models/ConsumeInventoryItemRequest.cs b/microservices/InventoryApi/Models/ConsumeInventoryItemRequest.cs new file mode 100644 index 0000000..48d234a --- /dev/null +++ b/microservices/InventoryApi/Models/ConsumeInventoryItemRequest.cs @@ -0,0 +1,6 @@ +namespace InventoryApi.Models; + +public class ConsumeInventoryItemRequest +{ + public int Quantity { get; set; } = 1; +} diff --git a/microservices/InventoryApi/Models/EquipInventoryItemRequest.cs b/microservices/InventoryApi/Models/EquipInventoryItemRequest.cs new file mode 100644 index 0000000..303860f --- /dev/null +++ b/microservices/InventoryApi/Models/EquipInventoryItemRequest.cs @@ -0,0 +1,8 @@ +namespace InventoryApi.Models; + +public class EquipInventoryItemRequest +{ + public string OwnerId { get; set; } = string.Empty; + + public string EquipmentSlot { get; set; } = string.Empty; +} diff --git a/microservices/InventoryApi/Models/GrantInventoryItemRequest.cs b/microservices/InventoryApi/Models/GrantInventoryItemRequest.cs new file mode 100644 index 0000000..ad96551 --- /dev/null +++ b/microservices/InventoryApi/Models/GrantInventoryItemRequest.cs @@ -0,0 +1,10 @@ +namespace InventoryApi.Models; + +public class GrantInventoryItemRequest +{ + public string ItemKey { get; set; } = string.Empty; + + public int Quantity { get; set; } = 1; + + public int? PreferredSlot { get; set; } +} diff --git a/microservices/InventoryApi/Models/InventoryItem.cs b/microservices/InventoryApi/Models/InventoryItem.cs new file mode 100644 index 0000000..6bd1703 --- /dev/null +++ b/microservices/InventoryApi/Models/InventoryItem.cs @@ -0,0 +1,38 @@ +using MongoDB.Bson.Serialization.Attributes; + +namespace InventoryApi.Models; + +public class InventoryItem +{ + [BsonId] + public string Id { get; set; } = Guid.NewGuid().ToString(); + + [BsonElement("itemKey")] + public string ItemKey { get; set; } = string.Empty; + + [BsonElement("quantity")] + public int Quantity { get; set; } = 1; + + [BsonElement("ownerType")] + public string OwnerType { get; set; } = string.Empty; + + [BsonElement("ownerId")] + public string OwnerId { get; set; } = string.Empty; + + [BsonElement("ownerUserId")] + public string? OwnerUserId { get; set; } + + [BsonElement("slot")] + [BsonIgnoreIfNull] + public int? Slot { get; set; } + + [BsonElement("equippedSlot")] + [BsonIgnoreIfNull] + public string? EquippedSlot { get; set; } + + [BsonElement("createdUtc")] + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; + + [BsonElement("updatedUtc")] + public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; +} diff --git a/microservices/InventoryApi/Models/InventoryItemResponse.cs b/microservices/InventoryApi/Models/InventoryItemResponse.cs new file mode 100644 index 0000000..d4b4d6f --- /dev/null +++ b/microservices/InventoryApi/Models/InventoryItemResponse.cs @@ -0,0 +1,32 @@ +namespace InventoryApi.Models; + +public class InventoryItemResponse +{ + public string Id { get; set; } = string.Empty; + + public string ItemKey { get; set; } = string.Empty; + + public int Quantity { get; set; } + + public string OwnerType { get; set; } = string.Empty; + + public string OwnerId { get; set; } = string.Empty; + + public int? Slot { get; set; } + + public string? EquippedSlot { get; set; } + + public DateTime UpdatedUtc { get; set; } + + public static InventoryItemResponse FromModel(InventoryItem item) => new() + { + Id = item.Id, + ItemKey = item.ItemKey, + Quantity = item.Quantity, + OwnerType = item.OwnerType, + OwnerId = item.OwnerId, + Slot = item.Slot, + EquippedSlot = item.EquippedSlot, + UpdatedUtc = item.UpdatedUtc + }; +} diff --git a/microservices/InventoryApi/Models/InventoryMutationResult.cs b/microservices/InventoryApi/Models/InventoryMutationResult.cs new file mode 100644 index 0000000..09946f4 --- /dev/null +++ b/microservices/InventoryApi/Models/InventoryMutationResult.cs @@ -0,0 +1,22 @@ +namespace InventoryApi.Models; + +public enum InventoryMutationStatus +{ + Ok, + ItemNotFound, + Invalid, + Conflict +} + +public class InventoryMutationResult +{ + public InventoryMutationStatus Status { get; init; } = InventoryMutationStatus.Ok; + + public string OwnerType { get; init; } = string.Empty; + + public string OwnerId { get; init; } = string.Empty; + + public List Items { get; init; } = []; + + public static implicit operator InventoryMutationStatus(InventoryMutationResult result) => result.Status; +} diff --git a/microservices/InventoryApi/Models/InventoryOwnerResponse.cs b/microservices/InventoryApi/Models/InventoryOwnerResponse.cs new file mode 100644 index 0000000..e3af042 --- /dev/null +++ b/microservices/InventoryApi/Models/InventoryOwnerResponse.cs @@ -0,0 +1,10 @@ +namespace InventoryApi.Models; + +public class InventoryOwnerResponse +{ + public string OwnerType { get; set; } = string.Empty; + + public string OwnerId { get; set; } = string.Empty; + + public List Items { get; set; } = []; +} diff --git a/microservices/InventoryApi/Models/MoveInventoryItemRequest.cs b/microservices/InventoryApi/Models/MoveInventoryItemRequest.cs new file mode 100644 index 0000000..f7c8419 --- /dev/null +++ b/microservices/InventoryApi/Models/MoveInventoryItemRequest.cs @@ -0,0 +1,10 @@ +namespace InventoryApi.Models; + +public class MoveInventoryItemRequest +{ + public string ItemId { get; set; } = string.Empty; + + public int ToSlot { get; set; } + + public int? Quantity { get; set; } +} diff --git a/microservices/InventoryApi/Models/OwnerAccessResult.cs b/microservices/InventoryApi/Models/OwnerAccessResult.cs new file mode 100644 index 0000000..6453864 --- /dev/null +++ b/microservices/InventoryApi/Models/OwnerAccessResult.cs @@ -0,0 +1,16 @@ +namespace InventoryApi.Models; + +public class OwnerAccessResult +{ + public bool IsSupported { get; init; } + + public bool Exists { get; init; } + + public bool IsAuthorized { get; init; } + + public string OwnerType { get; init; } = string.Empty; + + public string OwnerId { get; init; } = string.Empty; + + public string? OwnerUserId { get; init; } +} diff --git a/microservices/InventoryApi/Models/TransferInventoryItemRequest.cs b/microservices/InventoryApi/Models/TransferInventoryItemRequest.cs new file mode 100644 index 0000000..ad89bfb --- /dev/null +++ b/microservices/InventoryApi/Models/TransferInventoryItemRequest.cs @@ -0,0 +1,18 @@ +namespace InventoryApi.Models; + +public class TransferInventoryItemRequest +{ + public string ItemId { get; set; } = string.Empty; + + public string FromOwnerType { get; set; } = string.Empty; + + public string FromOwnerId { get; set; } = string.Empty; + + public string ToOwnerType { get; set; } = string.Empty; + + public string ToOwnerId { get; set; } = string.Empty; + + public int? ToSlot { get; set; } + + public int? Quantity { get; set; } +} diff --git a/microservices/InventoryApi/Models/TransferInventoryResponse.cs b/microservices/InventoryApi/Models/TransferInventoryResponse.cs new file mode 100644 index 0000000..a211c13 --- /dev/null +++ b/microservices/InventoryApi/Models/TransferInventoryResponse.cs @@ -0,0 +1,18 @@ +namespace InventoryApi.Models; + +public class TransferInventoryResponse +{ + public string MovedItemId { get; set; } = string.Empty; + + public string FromOwnerType { get; set; } = string.Empty; + + public string FromOwnerId { get; set; } = string.Empty; + + public string ToOwnerType { get; set; } = string.Empty; + + public string ToOwnerId { get; set; } = string.Empty; + + public List FromItems { get; set; } = []; + + public List ToItems { get; set; } = []; +} diff --git a/microservices/InventoryApi/Models/UnequipInventoryItemRequest.cs b/microservices/InventoryApi/Models/UnequipInventoryItemRequest.cs new file mode 100644 index 0000000..d63122a --- /dev/null +++ b/microservices/InventoryApi/Models/UnequipInventoryItemRequest.cs @@ -0,0 +1,8 @@ +namespace InventoryApi.Models; + +public class UnequipInventoryItemRequest +{ + public string OwnerId { get; set; } = string.Empty; + + public int? PreferredSlot { get; set; } +} diff --git a/microservices/InventoryApi/Program.cs b/microservices/InventoryApi/Program.cs new file mode 100644 index 0000000..33e9252 --- /dev/null +++ b/microservices/InventoryApi/Program.cs @@ -0,0 +1,106 @@ +using InventoryApi.Services; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; +using System.Text; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddControllers(); + +builder.Services.AddSingleton(); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => +{ + c.SwaggerDoc("v1", new OpenApiInfo { Title = "Inventory API", Version = "v1" }); + c.AddSecurityDefinition("bearerAuth", new OpenApiSecurityScheme + { + Type = SecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "JWT", + Description = "Paste your access token here (no 'Bearer ' prefix needed)." + }); + c.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { Type = ReferenceType.SecurityScheme, Id = "bearerAuth" } + }, + Array.Empty() + } + }); +}); + +var cfg = builder.Configuration; +var jwtKey = cfg["Jwt:Key"] ?? throw new Exception("Jwt:Key missing"); +var issuer = cfg["Jwt:Issuer"] ?? "promiscuity"; +var aud = cfg["Jwt:Audience"] ?? issuer; + +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(o => + { + o.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = issuer, + ValidateAudience = true, + ValidAudience = aud, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)), + ValidateLifetime = true, + ClockSkew = TimeSpan.FromSeconds(30) + }; + }); + +builder.Services.AddAuthorization(); + +var app = builder.Build(); + +app.UseExceptionHandler(errorApp => +{ + errorApp.Run(async context => + { + var feature = context.Features.Get(); + var exception = feature?.Error; + var logger = context.RequestServices.GetRequiredService().CreateLogger("GlobalException"); + var traceId = context.TraceIdentifier; + + if (exception is not null) + { + logger.LogError( + exception, + "Unhandled exception for {Method} {Path}. TraceId={TraceId}", + context.Request.Method, + context.Request.Path, + traceId + ); + } + + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + context.Response.ContentType = "application/problem+json"; + + await context.Response.WriteAsJsonAsync(new + { + type = "https://httpstatuses.com/500", + title = "Internal Server Error", + status = 500, + detail = exception?.Message ?? "An unexpected server error occurred.", + traceId + }); + }); +}); + +app.MapGet("/healthz", () => Results.Ok("ok")); +app.UseSwagger(); +app.UseSwaggerUI(o => +{ + o.SwaggerEndpoint("/swagger/v1/swagger.json", "Inventory API v1"); + o.RoutePrefix = "swagger"; +}); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); +app.Run(); diff --git a/microservices/InventoryApi/Properties/launchSettings.json b/microservices/InventoryApi/Properties/launchSettings.json new file mode 100644 index 0000000..b70cfb5 --- /dev/null +++ b/microservices/InventoryApi/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5184", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/microservices/InventoryApi/README.md b/microservices/InventoryApi/README.md new file mode 100644 index 0000000..9afa356 --- /dev/null +++ b/microservices/InventoryApi/README.md @@ -0,0 +1,116 @@ +# InventoryApi + +## Purpose +Owns item-instance state. + +This service should answer: +- what item records currently belong to a character, location, or other container owner +- where each item currently is +- whether an item move, split, merge, equip, or consume action is valid +- which specific item instance is being traded, auctioned, or transferred + +This service should not own: +- auth/token issuance +- character identity creation +- location generation +- auction bidding logic + +## Ownership model +- every inventory record has an `ownerType` and `ownerId` +- `USER` can read and mutate items owned by their own characters +- `USER` can read and mutate location/container items only when gameplay rules allow it +- `SUPER` can read and mutate any inventory item +- access should be resolved through the current owner, not from the item id alone + +## Initial design +Use one MongoDB document per inventory item record. + +Practical interpretation: +- non-stackable items: one document per item instance +- stackable items: one document per stack + +Reasons: +- every item or stack gets a stable UUID, which is useful for auctions, trade, mail, and auditing +- ownership transfer is explicit and cheap: update `ownerType`, `ownerId`, and slot fields +- future item metadata like durability, rarity rolls, or provenance can live on the item document +- auction listings can point to specific item ids instead of vague stack descriptions + +Tradeoffs: +- inventory reads are queries over many documents instead of one aggregate read +- stack merging and slot enforcement need careful indexes and mutation logic +- transfers should use transactions when they touch multiple item documents + +## Suggested endpoints +- `GET /api/inventory/by-owner/{ownerType}/{ownerId}` + Return all item records currently owned by that container owner. +- `POST /api/inventory/by-owner/{ownerType}/{ownerId}/grant` + Create a new item record or add quantity into an existing compatible stack. +- `POST /api/inventory/by-owner/{ownerType}/{ownerId}/move` + Move an item or stack within the same owner inventory. +- `POST /api/inventory/transfer` + Move quantity from one owner inventory to another. +- `POST /api/inventory/items/{itemId}/consume` + Consume quantity from a specific item record. +- `POST /api/inventory/items/{itemId}/equip` + Equip a specific item into a character equipment slot. +- `POST /api/inventory/items/{itemId}/unequip` + Return an equipped item to a character inventory slot. + +Notes: +- `equip` and `unequip` only make sense for character-owned items +- `transfer` is the core world interaction primitive for looting, dropping, trading, chest interaction, and auction handoff +- a future AuctionApi can reserve or re-own specific item ids without redesigning InventoryApi + +## Item identity +Every inventory record should have a stable UUID string such as: +- `a8d4218b-5e20-4e47-8b5f-0f0f0b9d7e10` + +Each record also carries an `itemKey` such as: +- `wood` +- `stone` +- `pistol` +- `small_health_potion` + +Recommended distinction: +- `id`: unique item-record identifier used for ownership changes, auctions, and references +- `itemKey`: item definition identifier used to decide stackability and gameplay behavior + +## Recommended stored shape +Each item document should include: +- `id` +- `itemKey` +- `quantity` +- `ownerType` +- `ownerId` +- `slot` +- `equippedSlot` +- `ownerUserId` when applicable +- `createdUtc` +- `updatedUtc` + +Optional future fields: +- `durability` +- `rarity` +- `instanceData` +- `listingId` +- `reservedUntilUtc` + +## MVP rules +- an item record must belong to exactly one owner at a time +- stackable items may share `itemKey` but should still be represented by one stack record per occupied slot +- non-stackable items must always have `quantity = 1` +- equipped items should set `equippedSlot` and clear `slot` +- unequipped bag items should set `slot` and clear `equippedSlot` +- slot occupancy must be unique per `(ownerType, ownerId, slot)` +- all mutating endpoints should be idempotent where practical + +## Client shape +The Godot client should fetch all items for the currently relevant owner and group them into a bag view locally. + +Good pattern: +- login/select character +- `GET /api/inventory/by-owner/character/{characterId}` +- when opening a stash or world container: `GET /api/inventory/by-owner/location/{locationId}` +- cache the returned item records locally +- call transfer/equip/consume endpoints using specific `itemId` values +- replace local state with the server response after each mutation diff --git a/microservices/InventoryApi/Services/InventoryStore.cs b/microservices/InventoryApi/Services/InventoryStore.cs new file mode 100644 index 0000000..823ec97 --- /dev/null +++ b/microservices/InventoryApi/Services/InventoryStore.cs @@ -0,0 +1,509 @@ +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 _items; + private readonly IMongoCollection _characters; + private readonly IMongoCollection _locations; + private readonly IMongoClient _client; + private readonly string _dbName; + private readonly HashSet _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("InventoryItems"); + _characters = db.GetCollection("Characters"); + _locations = db.GetCollection("Locations"); + + EnsureIndexes(); + } + + public async Task 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> 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> 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 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 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 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 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 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 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 FindItemBySlotAsync(string ownerType, string ownerId, int slot, IClientSessionHandle? session = null) + => session is null + ? FindItemBySlotNoSessionAsync(ownerType, ownerId, slot) + : FindItemBySlotWithSessionAsync(ownerType, ownerId, slot, session); + + private Task 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( + Builders.IndexKeys.Ascending(i => i.Id), + new CreateIndexOptions { Unique = true, Name = ItemIdIndexName })); + + _items.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(i => i.OwnerType).Ascending(i => i.OwnerId), + new CreateIndexOptions { Name = OwnerIndexName })); + + _items.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(i => i.OwnerType).Ascending(i => i.OwnerId).Ascending(i => i.Slot), + new CreateIndexOptions + { + Unique = true, + Name = SlotIndexName, + PartialFilterExpression = Builders.Filter.Ne(i => i.Slot, null) + })); + + _items.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(i => i.OwnerType).Ascending(i => i.OwnerId).Ascending(i => i.EquippedSlot), + new CreateIndexOptions + { + Unique = true, + Name = EquippedSlotIndexName, + PartialFilterExpression = Builders.Filter.Ne(i => i.EquippedSlot, null) + })); + } + + private async Task FindItemBySlotNoSessionAsync(string ownerType, string ownerId, int slot) => + await _items.Find(i => i.OwnerType == ownerType && i.OwnerId == ownerId && i.Slot == slot).FirstOrDefaultAsync(); + + private async Task 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 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 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; } + } +} diff --git a/microservices/InventoryApi/appsettings.Development.json b/microservices/InventoryApi/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/microservices/InventoryApi/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/microservices/InventoryApi/appsettings.json b/microservices/InventoryApi/appsettings.json new file mode 100644 index 0000000..b461ae5 --- /dev/null +++ b/microservices/InventoryApi/appsettings.json @@ -0,0 +1,7 @@ +{ + "Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5003" } } }, + "MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" }, + "Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" }, + "Logging": { "LogLevel": { "Default": "Information" } }, + "AllowedHosts": "*" +} diff --git a/microservices/InventoryApi/k8s/deployment.yaml b/microservices/InventoryApi/k8s/deployment.yaml new file mode 100644 index 0000000..a54590b --- /dev/null +++ b/microservices/InventoryApi/k8s/deployment.yaml @@ -0,0 +1,28 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: promiscuity-inventory + labels: + app: promiscuity-inventory +spec: + replicas: 2 + selector: + matchLabels: + app: promiscuity-inventory + template: + metadata: + labels: + app: promiscuity-inventory + spec: + containers: + - name: promiscuity-inventory + image: promiscuity-inventory:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 5003 + readinessProbe: + httpGet: + path: /healthz + port: 5003 + initialDelaySeconds: 5 + periodSeconds: 10 diff --git a/microservices/InventoryApi/k8s/service.yaml b/microservices/InventoryApi/k8s/service.yaml new file mode 100644 index 0000000..8417b75 --- /dev/null +++ b/microservices/InventoryApi/k8s/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: promiscuity-inventory + labels: + app: promiscuity-inventory +spec: + selector: + app: promiscuity-inventory + type: NodePort + ports: + - name: http + port: 80 + targetPort: 5003 + nodePort: 30083 diff --git a/microservices/README.md b/microservices/README.md index f849d14..bb28261 100644 --- a/microservices/README.md +++ b/microservices/README.md @@ -3,10 +3,12 @@ ## Document shapes - AuthApi: `AuthApi/DOCUMENTS.md` (auth request payloads and user document shape) - CharacterApi: `CharacterApi/DOCUMENTS.md` (character create payload and stored document) +- InventoryApi: `InventoryApi/DOCUMENTS.md` (inventory mutation payloads and stored document) - LocationsApi: `LocationsApi/DOCUMENTS.md` (location create/update payloads and stored document) ## Service READMEs - AuthApi: `AuthApi/README.md` - CharacterApi: `CharacterApi/README.md` +- InventoryApi: `InventoryApi/README.md` - LocationsApi: `LocationsApi/README.md` diff --git a/microservices/micro-services.sln b/microservices/micro-services.sln index 4d1a08d..685a13a 100644 --- a/microservices/micro-services.sln +++ b/microservices/micro-services.sln @@ -8,11 +8,13 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CharacterApi", "CharacterAp EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LocationsApi", "LocationsApi\LocationsApi.csproj", "{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}" EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InventoryApi", "InventoryApi\InventoryApi.csproj", "{72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Debug|Any CPU.Build.0 = Debug|Any CPU @@ -26,7 +28,11 @@ Global {C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Debug|Any CPU.Build.0 = Debug|Any CPU {C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Release|Any CPU.ActiveCfg = Release|Any CPU {C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection + {72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection