Gathering infra
All checks were successful
Deploy Promiscuity Auth API / deploy (push) Successful in 46s
Deploy Promiscuity Character API / deploy (push) Successful in 45s
Deploy Promiscuity Inventory API / deploy (push) Successful in 46s
Deploy Promiscuity Locations API / deploy (push) Successful in 1m3s
k8s smoke test / test (push) Successful in 7s

This commit is contained in:
Zeeshaun 2026-03-15 14:04:12 -05:00
parent a2a4d48de5
commit 9ba725d207
10 changed files with 273 additions and 22 deletions

View File

@ -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<IActionResult> 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
});
}
}

View File

@ -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)"
}
```

View File

@ -0,0 +1,8 @@
namespace LocationsApi.Models;
public class GatherResourceRequest
{
public string CharacterId { get; set; } = string.Empty;
public string ResourceKey { get; set; } = string.Empty;
}

View File

@ -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;
}

View File

@ -15,6 +15,9 @@ public class Location
[BsonElement("coord")]
public required Coord Coord { get; set; }
[BsonElement("resources")]
public List<LocationResource> Resources { get; set; } = [];
[BsonElement("createdUtc")]
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
}

View File

@ -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;
}

View File

@ -6,6 +6,7 @@ using System.Text;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddHttpClient();
// DI
builder.Services.AddSingleton<LocationStore>();

View File

@ -8,6 +8,7 @@ public class LocationStore
{
private readonly IMongoCollection<Location> _col;
private readonly IMongoCollection<BsonDocument> _rawCol;
private readonly IMongoCollection<CharacterDocument> _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<Location>(collectionName);
_rawCol = db.GetCollection<BsonDocument>(collectionName);
_characters = db.GetCollection<CharacterDocument>("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<GatherResult> 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<Location>.Filter.And(
Builders<Location>.Filter.Eq(l => l.Id, locationId),
Builders<Location>.Filter.ElemMatch(l => l.Resources, r => r.ItemKey == resource.ItemKey && r.RemainingQuantity >= quantityGranted)
);
var update = Builders<Location>.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<Location>.Filter.And(
Builders<Location>.Filter.Eq(l => l.Id, locationId),
Builders<Location>.Filter.ElemMatch(l => l.Resources, r => r.ItemKey == normalizedKey)
);
var update = Builders<Location>.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
}

View File

@ -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"
}
}
}

View File

@ -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": "*"