diff --git a/microservices/LocationsApi/Controllers/LocationsController.cs b/microservices/LocationsApi/Controllers/LocationsController.cs index 6c9886f..aa33f88 100644 --- a/microservices/LocationsApi/Controllers/LocationsController.cs +++ b/microservices/LocationsApi/Controllers/LocationsController.cs @@ -3,6 +3,10 @@ using LocationsApi.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using MongoDB.Driver; +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text; +using System.Text.Json; namespace LocationsApi.Controllers; @@ -11,10 +15,14 @@ namespace LocationsApi.Controllers; public class LocationsController : ControllerBase { private readonly LocationStore _locations; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IConfiguration _configuration; - public LocationsController(LocationStore locations) + public LocationsController(LocationStore locations, IHttpClientFactory httpClientFactory, IConfiguration configuration) { _locations = locations; + _httpClientFactory = httpClientFactory; + _configuration = configuration; } [HttpPost] @@ -85,4 +93,67 @@ public class LocationsController : ControllerBase return Ok("Updated"); } + + [HttpPost("{id}/gather")] + [Authorize(Roles = "USER,SUPER")] + public async Task Gather(string id, [FromBody] GatherResourceRequest req) + { + if (string.IsNullOrWhiteSpace(req.CharacterId)) + return BadRequest("characterId required"); + if (string.IsNullOrWhiteSpace(req.ResourceKey)) + return BadRequest("resourceKey required"); + + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(userId)) + return Unauthorized(); + + var allowAnyOwner = User.IsInRole("SUPER"); + var gather = await _locations.GatherResourceAsync(id, req.CharacterId, req.ResourceKey, userId, allowAnyOwner); + if (gather.Status == GatherStatus.LocationNotFound) + return NotFound("Location not found"); + if (gather.Status == GatherStatus.CharacterNotFound) + return NotFound("Character not found"); + if (gather.Status == GatherStatus.Forbidden) + return Forbid(); + if (gather.Status == GatherStatus.Invalid) + return BadRequest("Character is not at the target location"); + if (gather.Status == GatherStatus.ResourceNotFound) + return NotFound("Resource not found at location"); + if (gather.Status == GatherStatus.ResourceDepleted) + return Conflict("Resource is depleted"); + + var inventoryBaseUrl = (_configuration["Services:InventoryApiBaseUrl"] ?? "http://localhost:5003").TrimEnd('/'); + var token = Request.Headers.Authorization.ToString(); + var grantBody = JsonSerializer.Serialize(new + { + itemKey = gather.ResourceKey, + quantity = gather.QuantityGranted + }); + + var client = _httpClientFactory.CreateClient(); + using var request = new HttpRequestMessage( + HttpMethod.Post, + $"{inventoryBaseUrl}/api/inventory/by-owner/character/{req.CharacterId}/grant"); + request.Content = new StringContent(grantBody, Encoding.UTF8, "application/json"); + if (!string.IsNullOrWhiteSpace(token)) + request.Headers.Authorization = AuthenticationHeaderValue.Parse(token); + + using var response = await client.SendAsync(request); + var responseBody = await response.Content.ReadAsStringAsync(); + if (!response.IsSuccessStatusCode) + { + await _locations.RestoreGatheredResourceAsync(id, gather.ResourceKey, gather.QuantityGranted); + return StatusCode((int)response.StatusCode, responseBody); + } + + return Ok(new GatherResourceResponse + { + LocationId = id, + CharacterId = req.CharacterId, + ResourceKey = gather.ResourceKey, + QuantityGranted = gather.QuantityGranted, + RemainingQuantity = gather.RemainingQuantity, + InventoryResponseJson = responseBody + }); + } } diff --git a/microservices/LocationsApi/DOCUMENTS.md b/microservices/LocationsApi/DOCUMENTS.md index 0df5336..9b85cb9 100644 --- a/microservices/LocationsApi/DOCUMENTS.md +++ b/microservices/LocationsApi/DOCUMENTS.md @@ -21,6 +21,13 @@ Inbound JSON documents } ``` `coord` cannot be updated. +- GatherResourceRequest (`POST /api/locations/{id}/gather`) + ```json + { + "characterId": "string (Character ObjectId)", + "resourceKey": "wood" + } + ``` Stored documents (MongoDB) - Location @@ -32,6 +39,26 @@ Stored documents (MongoDB) "x": 0, "y": 0 }, + "resources": [ + { + "itemKey": "wood", + "remainingQuantity": 100, + "gatherQuantity": 3 + } + ], "createdUtc": "string (ISO-8601 datetime)" } ``` + +Outbound JSON documents +- GatherResourceResponse (`POST /api/locations/{id}/gather`) + ```json + { + "locationId": "string (ObjectId)", + "characterId": "string (ObjectId)", + "resourceKey": "wood", + "quantityGranted": 3, + "remainingQuantity": 97, + "inventoryResponseJson": "string (raw InventoryApi response JSON)" + } + ``` diff --git a/microservices/LocationsApi/Models/GatherResourceRequest.cs b/microservices/LocationsApi/Models/GatherResourceRequest.cs new file mode 100644 index 0000000..ec7a07f --- /dev/null +++ b/microservices/LocationsApi/Models/GatherResourceRequest.cs @@ -0,0 +1,8 @@ +namespace LocationsApi.Models; + +public class GatherResourceRequest +{ + public string CharacterId { get; set; } = string.Empty; + + public string ResourceKey { get; set; } = string.Empty; +} diff --git a/microservices/LocationsApi/Models/GatherResourceResponse.cs b/microservices/LocationsApi/Models/GatherResourceResponse.cs new file mode 100644 index 0000000..092d5c2 --- /dev/null +++ b/microservices/LocationsApi/Models/GatherResourceResponse.cs @@ -0,0 +1,16 @@ +namespace LocationsApi.Models; + +public class GatherResourceResponse +{ + public string LocationId { get; set; } = string.Empty; + + public string CharacterId { get; set; } = string.Empty; + + public string ResourceKey { get; set; } = string.Empty; + + public int QuantityGranted { get; set; } + + public int RemainingQuantity { get; set; } + + public string InventoryResponseJson { get; set; } = string.Empty; +} diff --git a/microservices/LocationsApi/Models/Location.cs b/microservices/LocationsApi/Models/Location.cs index 462e763..f477932 100644 --- a/microservices/LocationsApi/Models/Location.cs +++ b/microservices/LocationsApi/Models/Location.cs @@ -15,6 +15,9 @@ public class Location [BsonElement("coord")] public required Coord Coord { get; set; } + [BsonElement("resources")] + public List Resources { get; set; } = []; + [BsonElement("createdUtc")] public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; } diff --git a/microservices/LocationsApi/Models/LocationResource.cs b/microservices/LocationsApi/Models/LocationResource.cs new file mode 100644 index 0000000..8d955ca --- /dev/null +++ b/microservices/LocationsApi/Models/LocationResource.cs @@ -0,0 +1,15 @@ +using MongoDB.Bson.Serialization.Attributes; + +namespace LocationsApi.Models; + +public class LocationResource +{ + [BsonElement("itemKey")] + public string ItemKey { get; set; } = string.Empty; + + [BsonElement("remainingQuantity")] + public int RemainingQuantity { get; set; } + + [BsonElement("gatherQuantity")] + public int GatherQuantity { get; set; } = 1; +} diff --git a/microservices/LocationsApi/Program.cs b/microservices/LocationsApi/Program.cs index c4da181..fdfbe7c 100644 --- a/microservices/LocationsApi/Program.cs +++ b/microservices/LocationsApi/Program.cs @@ -6,6 +6,7 @@ using System.Text; var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); +builder.Services.AddHttpClient(); // DI builder.Services.AddSingleton(); diff --git a/microservices/LocationsApi/Services/LocationStore.cs b/microservices/LocationsApi/Services/LocationStore.cs index 1d589a1..570dbda 100644 --- a/microservices/LocationsApi/Services/LocationStore.cs +++ b/microservices/LocationsApi/Services/LocationStore.cs @@ -8,6 +8,7 @@ public class LocationStore { private readonly IMongoCollection _col; private readonly IMongoCollection _rawCol; + private readonly IMongoCollection _characters; private const string CoordIndexName = "coord_x_1_coord_y_1"; public LocationStore(IConfiguration cfg) @@ -20,6 +21,7 @@ public class LocationStore EnsureLocationSchema(db, collectionName); _col = db.GetCollection(collectionName); _rawCol = db.GetCollection(collectionName); + _characters = db.GetCollection("Characters"); EnsureCoordIndexes(); @@ -34,11 +36,11 @@ public class LocationStore "$jsonSchema", new BsonDocument { { "bsonType", "object" }, - { "required", new BsonArray { "name", "coord", "createdUtc" } }, - { - "properties", new BsonDocument - { - { "name", new BsonDocument { { "bsonType", "string" } } }, + { "required", new BsonArray { "name", "coord", "createdUtc" } }, + { + "properties", new BsonDocument + { + { "name", new BsonDocument { { "bsonType", "string" } } }, { "coord", new BsonDocument { @@ -52,10 +54,31 @@ public class LocationStore } } } - }, - { "createdUtc", new BsonDocument { { "bsonType", "date" } } } - } - } + }, + { + "resources", new BsonDocument + { + { "bsonType", new BsonArray { "array", "null" } }, + { + "items", new BsonDocument + { + { "bsonType", "object" }, + { "required", new BsonArray { "itemKey", "remainingQuantity", "gatherQuantity" } }, + { + "properties", new BsonDocument + { + { "itemKey", new BsonDocument { { "bsonType", "string" } } }, + { "remainingQuantity", new BsonDocument { { "bsonType", "int" }, { "minimum", 0 } } }, + { "gatherQuantity", new BsonDocument { { "bsonType", "int" }, { "minimum", 1 } } } + } + } + } + } + } + }, + { "createdUtc", new BsonDocument { { "bsonType", "date" } } } + } + } } } }; @@ -102,6 +125,57 @@ public class LocationStore return result.ModifiedCount > 0; } + public sealed record GatherResult(GatherStatus Status, string ResourceKey = "", int QuantityGranted = 0, int RemainingQuantity = 0); + + public async Task GatherResourceAsync(string locationId, string characterId, string resourceKey, string userId, bool allowAnyOwner) + { + var normalizedKey = resourceKey.Trim().ToLowerInvariant(); + var location = await _col.Find(l => l.Id == locationId).FirstOrDefaultAsync(); + if (location is null) + return new GatherResult(GatherStatus.LocationNotFound); + + var character = await _characters.Find(c => c.Id == characterId).FirstOrDefaultAsync(); + if (character is null) + return new GatherResult(GatherStatus.CharacterNotFound); + if (!allowAnyOwner && character.OwnerUserId != userId) + return new GatherResult(GatherStatus.Forbidden); + if (character.Coord.X != location.Coord.X || character.Coord.Y != location.Coord.Y) + return new GatherResult(GatherStatus.Invalid); + + var resource = location.Resources.FirstOrDefault(r => NormalizeItemKey(r.ItemKey) == normalizedKey); + if (resource is null) + return new GatherResult(GatherStatus.ResourceNotFound); + if (resource.RemainingQuantity <= 0) + return new GatherResult(GatherStatus.ResourceDepleted); + + var quantityGranted = Math.Min(resource.GatherQuantity, resource.RemainingQuantity); + var filter = Builders.Filter.And( + Builders.Filter.Eq(l => l.Id, locationId), + Builders.Filter.ElemMatch(l => l.Resources, r => r.ItemKey == resource.ItemKey && r.RemainingQuantity >= quantityGranted) + ); + var update = Builders.Update.Inc("resources.$.remainingQuantity", -quantityGranted); + var result = await _col.UpdateOneAsync(filter, update); + if (result.ModifiedCount == 0) + return new GatherResult(GatherStatus.ResourceDepleted); + + return new GatherResult( + GatherStatus.Ok, + resource.ItemKey, + quantityGranted, + resource.RemainingQuantity - quantityGranted); + } + + public async Task RestoreGatheredResourceAsync(string locationId, string resourceKey, int quantity) + { + var normalizedKey = NormalizeItemKey(resourceKey); + var filter = Builders.Filter.And( + Builders.Filter.Eq(l => l.Id, locationId), + Builders.Filter.ElemMatch(l => l.Resources, r => r.ItemKey == normalizedKey) + ); + var update = Builders.Update.Inc("resources.$.remainingQuantity", quantity); + await _col.UpdateOneAsync(filter, update); + } + private void EnsureCoordIndexes() { var indexes = _rawCol.Indexes.List().ToList(); @@ -149,12 +223,17 @@ public class LocationStore if (existing is not null) return; - var origin = new Location - { - Name = "Origin", - Coord = new Coord { X = 0, Y = 0 }, - CreatedUtc = DateTime.UtcNow - }; + var origin = new Location + { + Name = "Origin", + Coord = new Coord { X = 0, Y = 0 }, + Resources = + [ + new LocationResource { ItemKey = "wood", RemainingQuantity = 100, GatherQuantity = 3 }, + new LocationResource { ItemKey = "grass", RemainingQuantity = 500, GatherQuantity = 10 } + ], + CreatedUtc = DateTime.UtcNow + }; try { @@ -164,5 +243,30 @@ public class LocationStore { // Another instance seeded it first. } - } -} + } + + private static string NormalizeItemKey(string itemKey) => itemKey.Trim().ToLowerInvariant(); + + [MongoDB.Bson.Serialization.Attributes.BsonIgnoreExtraElements] + private class CharacterDocument + { + [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; + + public Coord Coord { get; set; } = new(); + } +} + +public enum GatherStatus +{ + Ok, + LocationNotFound, + CharacterNotFound, + Forbidden, + Invalid, + ResourceNotFound, + ResourceDepleted +} diff --git a/microservices/LocationsApi/appsettings.Development.json b/microservices/LocationsApi/appsettings.Development.json index 07f3f94..c478bd3 100644 --- a/microservices/LocationsApi/appsettings.Development.json +++ b/microservices/LocationsApi/appsettings.Development.json @@ -1,6 +1,11 @@ { - "Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5002" } } }, - "MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" }, - "Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" }, - "Logging": { "LogLevel": { "Default": "Information" } } + "Services": { + "InventoryApiBaseUrl": "http://localhost:5003" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } } diff --git a/microservices/LocationsApi/appsettings.json b/microservices/LocationsApi/appsettings.json index d67c59f..45d1ba6 100644 --- a/microservices/LocationsApi/appsettings.json +++ b/microservices/LocationsApi/appsettings.json @@ -1,6 +1,7 @@ { "Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5002" } } }, "MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" }, + "Services": { "InventoryApiBaseUrl": "https://pinv.ranaze.com" }, "Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" }, "Logging": { "LogLevel": { "Default": "Information" } }, "AllowedHosts": "*"