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"
|
||||
|
||||
@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
|
||||
|
||||
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
|
||||
- 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`
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user