Adding inventory microservice
All checks were successful
All checks were successful
This commit is contained in:
parent
9809869cbe
commit
9a7d6544ef
@ -2,7 +2,7 @@ extends Node3D
|
|||||||
|
|
||||||
const CHARACTER_API_URL := "https://pchar.ranaze.com/api/Characters"
|
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 var block_height := 1.0
|
||||||
@export_range(1, 8, 1) var tile_radius := 3
|
@export_range(1, 8, 1) var tile_radius := 3
|
||||||
@export var tracked_node_path: NodePath
|
@export var tracked_node_path: NodePath
|
||||||
|
|||||||
248
microservices/InventoryApi/Controllers/InventoryController.cs
Normal file
248
microservices/InventoryApi/Controllers/InventoryController.cs
Normal file
@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
186
microservices/InventoryApi/DOCUMENTS.md
Normal file
186
microservices/InventoryApi/DOCUMENTS.md
Normal file
@ -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
|
||||||
19
microservices/InventoryApi/Dockerfile
Normal file
19
microservices/InventoryApi/Dockerfile
Normal file
@ -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"]
|
||||||
16
microservices/InventoryApi/InventoryApi.csproj
Normal file
16
microservices/InventoryApi/InventoryApi.csproj
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" />
|
||||||
|
<PackageReference Include="MongoDB.Driver" Version="3.4.3" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
namespace InventoryApi.Models;
|
||||||
|
|
||||||
|
public class ConsumeInventoryItemRequest
|
||||||
|
{
|
||||||
|
public int Quantity { get; set; } = 1;
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
namespace InventoryApi.Models;
|
||||||
|
|
||||||
|
public class EquipInventoryItemRequest
|
||||||
|
{
|
||||||
|
public string OwnerId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string EquipmentSlot { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
38
microservices/InventoryApi/Models/InventoryItem.cs
Normal file
38
microservices/InventoryApi/Models/InventoryItem.cs
Normal file
@ -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;
|
||||||
|
}
|
||||||
32
microservices/InventoryApi/Models/InventoryItemResponse.cs
Normal file
32
microservices/InventoryApi/Models/InventoryItemResponse.cs
Normal file
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
22
microservices/InventoryApi/Models/InventoryMutationResult.cs
Normal file
22
microservices/InventoryApi/Models/InventoryMutationResult.cs
Normal file
@ -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<InventoryItem> Items { get; init; } = [];
|
||||||
|
|
||||||
|
public static implicit operator InventoryMutationStatus(InventoryMutationResult result) => result.Status;
|
||||||
|
}
|
||||||
10
microservices/InventoryApi/Models/InventoryOwnerResponse.cs
Normal file
10
microservices/InventoryApi/Models/InventoryOwnerResponse.cs
Normal file
@ -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<InventoryItemResponse> Items { get; set; } = [];
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
16
microservices/InventoryApi/Models/OwnerAccessResult.cs
Normal file
16
microservices/InventoryApi/Models/OwnerAccessResult.cs
Normal file
@ -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; }
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
@ -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<InventoryItemResponse> FromItems { get; set; } = [];
|
||||||
|
|
||||||
|
public List<InventoryItemResponse> ToItems { get; set; } = [];
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
namespace InventoryApi.Models;
|
||||||
|
|
||||||
|
public class UnequipInventoryItemRequest
|
||||||
|
{
|
||||||
|
public string OwnerId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int? PreferredSlot { get; set; }
|
||||||
|
}
|
||||||
106
microservices/InventoryApi/Program.cs
Normal file
106
microservices/InventoryApi/Program.cs
Normal file
@ -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<InventoryStore>();
|
||||||
|
|
||||||
|
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<string>()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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<IExceptionHandlerFeature>();
|
||||||
|
var exception = feature?.Error;
|
||||||
|
var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().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();
|
||||||
14
microservices/InventoryApi/Properties/launchSettings.json
Normal file
14
microservices/InventoryApi/Properties/launchSettings.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
116
microservices/InventoryApi/README.md
Normal file
116
microservices/InventoryApi/README.md
Normal file
@ -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
|
||||||
509
microservices/InventoryApi/Services/InventoryStore.cs
Normal file
509
microservices/InventoryApi/Services/InventoryStore.cs
Normal file
@ -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<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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
8
microservices/InventoryApi/appsettings.Development.json
Normal file
8
microservices/InventoryApi/appsettings.Development.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
microservices/InventoryApi/appsettings.json
Normal file
7
microservices/InventoryApi/appsettings.json
Normal file
@ -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": "*"
|
||||||
|
}
|
||||||
28
microservices/InventoryApi/k8s/deployment.yaml
Normal file
28
microservices/InventoryApi/k8s/deployment.yaml
Normal file
@ -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
|
||||||
15
microservices/InventoryApi/k8s/service.yaml
Normal file
15
microservices/InventoryApi/k8s/service.yaml
Normal file
@ -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
|
||||||
@ -3,10 +3,12 @@
|
|||||||
## Document shapes
|
## Document shapes
|
||||||
- AuthApi: `AuthApi/DOCUMENTS.md` (auth request payloads and user document shape)
|
- AuthApi: `AuthApi/DOCUMENTS.md` (auth request payloads and user document shape)
|
||||||
- CharacterApi: `CharacterApi/DOCUMENTS.md` (character create payload and stored document)
|
- 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)
|
- LocationsApi: `LocationsApi/DOCUMENTS.md` (location create/update payloads and stored document)
|
||||||
|
|
||||||
## Service READMEs
|
## Service READMEs
|
||||||
- AuthApi: `AuthApi/README.md`
|
- AuthApi: `AuthApi/README.md`
|
||||||
- CharacterApi: `CharacterApi/README.md`
|
- CharacterApi: `CharacterApi/README.md`
|
||||||
|
- InventoryApi: `InventoryApi/README.md`
|
||||||
- LocationsApi: `LocationsApi/README.md`
|
- LocationsApi: `LocationsApi/README.md`
|
||||||
|
|
||||||
|
|||||||
@ -8,11 +8,13 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CharacterApi", "CharacterAp
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LocationsApi", "LocationsApi\LocationsApi.csproj", "{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LocationsApi", "LocationsApi\LocationsApi.csproj", "{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}"
|
||||||
EndProject
|
EndProject
|
||||||
Global
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InventoryApi", "InventoryApi\InventoryApi.csproj", "{72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}"
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
EndProject
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Global
|
||||||
Release|Any CPU = Release|Any CPU
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
EndGlobalSection
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
{334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{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
|
{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}.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.ActiveCfg = Release|Any CPU
|
||||||
{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Release|Any CPU.Build.0 = 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
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user