Zeeshaun 038981d7b1
All checks were successful
Deploy Promiscuity Auth API / deploy (push) Successful in 46s
Deploy Promiscuity Character API / deploy (push) Successful in 57s
Deploy Promiscuity Inventory API / deploy (push) Successful in 59s
Deploy Promiscuity Locations API / deploy (push) Successful in 58s
k8s smoke test / test (push) Successful in 8s
Speeding up visible location call
2026-03-20 09:50:17 -05:00

422 lines
18 KiB
C#

using LocationsApi.Models;
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;
[ApiController]
[Route("api/[controller]")]
public class LocationsController : ControllerBase
{
private readonly LocationStore _locations;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IConfiguration _configuration;
private readonly ILogger<LocationsController> _logger;
public LocationsController(LocationStore locations, IHttpClientFactory httpClientFactory, IConfiguration configuration, ILogger<LocationsController> logger)
{
_locations = locations;
_httpClientFactory = httpClientFactory;
_configuration = configuration;
_logger = logger;
}
[HttpPost]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> Create([FromBody] CreateLocationRequest req)
{
if (string.IsNullOrWhiteSpace(req.Name))
return BadRequest("Name required");
if (req.Coord is null)
return BadRequest("Coord required");
var location = new Location
{
Name = req.Name.Trim(),
Coord = req.Coord,
CreatedUtc = DateTime.UtcNow
};
try
{
await _locations.CreateAsync(location);
}
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
{
return Conflict("Coord must be unique");
}
catch (MongoWriteException ex) when (ex.WriteError.Code == 121)
{
return BadRequest("Location document failed validation");
}
return Ok(location);
}
[HttpGet("biome-definitions")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> ListBiomeDefinitions()
{
var definitions = await _locations.GetBiomeDefinitionsAsync();
return Ok(definitions);
}
[HttpGet("biome-definitions/{biomeKey}")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> GetBiomeDefinition(string biomeKey)
{
var normalizedBiomeKey = NormalizeBiomeKey(biomeKey);
if (string.IsNullOrWhiteSpace(normalizedBiomeKey))
return BadRequest("biomeKey required");
var definition = await _locations.GetBiomeDefinitionAsync(normalizedBiomeKey);
return definition is null ? NotFound() : Ok(definition);
}
[HttpPost("internal/visible-window")]
public async Task<IActionResult> GetVisibleWindow([FromBody] InternalVisibleLocationsRequest req)
{
var configuredKey = (_configuration["InternalApi:Key"] ?? _configuration["Jwt:Key"] ?? string.Empty).Trim();
var requestKey = (Request.Headers["X-Internal-Api-Key"].FirstOrDefault() ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(configuredKey) || !string.Equals(configuredKey, requestKey, StringComparison.Ordinal))
return Unauthorized();
if (req.Radius < 0)
return BadRequest("radius must be non-negative");
var result = await _locations.GetOrCreateVisibleLocationsAsync(req.X, req.Y, req.Radius);
await PopulateFloorInventoriesAsync(result);
return Ok(result);
}
[HttpPost("biome-definitions/{biomeKey}")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> CreateBiomeDefinition(string biomeKey, [FromBody] UpsertBiomeDefinitionRequest req)
{
var validationError = ValidateBiomeDefinitionRequest(biomeKey, req);
if (validationError is not null)
return validationError;
var definition = BuildBiomeDefinition(biomeKey, req);
var created = await _locations.CreateBiomeDefinitionAsync(definition);
if (!created)
return Conflict("Biome definition already exists");
return Ok(definition);
}
[HttpPut("biome-definitions/{biomeKey}")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> UpsertBiomeDefinition(string biomeKey, [FromBody] UpsertBiomeDefinitionRequest req)
{
var validationError = ValidateBiomeDefinitionRequest(biomeKey, req);
if (validationError is not null)
return validationError;
var definition = await _locations.UpsertBiomeDefinitionAsync(biomeKey, req);
return Ok(definition);
}
[HttpGet]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> ListMine()
{
var locations = await _locations.GetAllAsync();
return Ok(locations);
}
[HttpDelete("{id}")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> Delete(string id)
{
var deleted = await _locations.DeleteAsync(id);
if (!deleted)
return NotFound();
return Ok("Deleted");
}
[HttpPut("{id}")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> Update(string id, [FromBody] UpdateLocationRequest req)
{
if (string.IsNullOrWhiteSpace(req.Name))
return BadRequest("Name required");
if (req.Coord is not null)
return BadRequest("Coord cannot be updated");
var updated = await _locations.UpdateNameAsync(id, req.Name.Trim());
if (!updated)
return NotFound();
return Ok("Updated");
}
[HttpPost("{id}/interact")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Interact(string id, [FromBody] InteractLocationObjectRequest req)
{
if (string.IsNullOrWhiteSpace(req.CharacterId))
return BadRequest("characterId required");
if (string.IsNullOrWhiteSpace(req.ObjectId))
return BadRequest("objectId required");
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
var allowAnyOwner = User.IsInRole("SUPER");
var interact = await _locations.InteractWithObjectAsync(id, req.CharacterId, req.ObjectId, userId, allowAnyOwner);
if (interact.Status == InteractStatus.LocationNotFound)
return NotFound("Location not found");
if (interact.Status == InteractStatus.CharacterNotFound)
return NotFound("Character not found");
if (interact.Status == InteractStatus.Forbidden)
return Forbid();
if (interact.Status == InteractStatus.Invalid)
return BadRequest("Character is not at the target location");
if (interact.Status == InteractStatus.ObjectNotFound)
return NotFound("Location object not found");
if (interact.Status == InteractStatus.UnsupportedObjectType)
return BadRequest("Location object type is not supported");
if (interact.Status == InteractStatus.ObjectConsumed)
return Conflict("Location object is consumed");
var inventoryBaseUrl = (_configuration["Services:InventoryApiBaseUrl"] ?? "http://localhost:5003").TrimEnd('/');
var token = Request.Headers.Authorization.ToString();
var grantBody = JsonSerializer.Serialize(new
{
itemKey = interact.ItemKey,
quantity = interact.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);
HttpResponseMessage response;
try
{
response = await client.SendAsync(request);
}
catch (HttpRequestException ex)
{
if (interact.PreviousObject is not null)
await _locations.RestoreObjectInteractionAsync(id, interact.PreviousObject);
_logger.LogError(
ex,
"Failed to reach InventoryApi while processing location interaction for location {LocationId}, character {CharacterId}, object {ObjectId}",
id,
req.CharacterId,
req.ObjectId
);
return StatusCode(StatusCodes.Status502BadGateway, new
{
type = "https://httpstatuses.com/502",
title = "Bad Gateway",
status = 502,
detail = $"Failed to reach InventoryApi at {inventoryBaseUrl}.",
traceId = HttpContext.TraceIdentifier
});
}
using (response)
{
var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
if (interact.PreviousObject is not null)
await _locations.RestoreObjectInteractionAsync(id, interact.PreviousObject);
return StatusCode((int)response.StatusCode, responseBody);
}
var characterGrantedQuantity = interact.QuantityGranted;
var floorGrantedQuantity = 0;
var overflowQuantity = 0;
if (!string.IsNullOrWhiteSpace(responseBody))
{
using var grantJson = JsonDocument.Parse(responseBody);
if (grantJson.RootElement.TryGetProperty("grantedQuantity", out var grantedElement) && grantedElement.ValueKind == JsonValueKind.Number)
characterGrantedQuantity = grantedElement.GetInt32();
if (grantJson.RootElement.TryGetProperty("overflowQuantity", out var overflowElement) && overflowElement.ValueKind == JsonValueKind.Number)
overflowQuantity = overflowElement.GetInt32();
}
if (overflowQuantity > 0)
{
var floorGrantBody = JsonSerializer.Serialize(new
{
itemKey = interact.ItemKey,
quantity = overflowQuantity
});
using var floorRequest = new HttpRequestMessage(
HttpMethod.Post,
$"{inventoryBaseUrl}/api/inventory/by-owner/location/{id}/grant");
floorRequest.Content = new StringContent(floorGrantBody, Encoding.UTF8, "application/json");
if (!string.IsNullOrWhiteSpace(token))
floorRequest.Headers.Authorization = AuthenticationHeaderValue.Parse(token);
using var floorResponse = await client.SendAsync(floorRequest);
var floorResponseBody = await floorResponse.Content.ReadAsStringAsync();
if (!floorResponse.IsSuccessStatusCode)
{
_logger.LogError(
"Character inventory overflow could not be redirected to location inventory. Location {LocationId}, character {CharacterId}, item {ItemKey}, quantity {Quantity}, response {StatusCode}: {Body}",
id,
req.CharacterId,
interact.ItemKey,
overflowQuantity,
(int)floorResponse.StatusCode,
floorResponseBody
);
return StatusCode((int)floorResponse.StatusCode, floorResponseBody);
}
if (!string.IsNullOrWhiteSpace(floorResponseBody))
{
using var floorJson = JsonDocument.Parse(floorResponseBody);
if (floorJson.RootElement.TryGetProperty("grantedQuantity", out var floorGrantedElement) && floorGrantedElement.ValueKind == JsonValueKind.Number)
floorGrantedQuantity = floorGrantedElement.GetInt32();
else
floorGrantedQuantity = overflowQuantity;
}
else
{
floorGrantedQuantity = overflowQuantity;
}
}
return Ok(new InteractLocationObjectResponse
{
LocationId = id,
CharacterId = req.CharacterId,
ObjectId = interact.ObjectId,
ObjectType = interact.ObjectType,
ItemKey = interact.ItemKey,
QuantityGranted = interact.QuantityGranted,
CharacterGrantedQuantity = characterGrantedQuantity,
FloorGrantedQuantity = floorGrantedQuantity,
RemainingQuantity = interact.RemainingQuantity,
Consumed = interact.Consumed,
InventoryResponseJson = responseBody
});
}
}
private IActionResult? ValidateBiomeDefinitionRequest(string biomeKey, UpsertBiomeDefinitionRequest req)
{
if (string.IsNullOrWhiteSpace(NormalizeBiomeKey(biomeKey)))
return BadRequest("biomeKey required");
if (req.ContinuationWeight < 0)
return BadRequest("continuationWeight must be non-negative");
if (req.TransitionWeights.Any(weight => string.IsNullOrWhiteSpace(weight.TargetBiomeKey)))
return BadRequest("transitionWeights require targetBiomeKey");
if (req.TransitionWeights.Any(weight => weight.Weight < 0))
return BadRequest("transitionWeights weight must be non-negative");
if (req.ObjectSpawnRules.Count == 0)
return BadRequest("objectSpawnRules required");
if (req.ObjectSpawnRules.Any(rule => rule.Weight < 0))
return BadRequest("objectSpawnRules weight must be non-negative");
if (req.ObjectSpawnRules.Any(rule => string.IsNullOrWhiteSpace(rule.ResultType)))
return BadRequest("objectSpawnRules resultType required");
if (req.ObjectSpawnRules.Any(rule =>
string.Equals(rule.ResultType, "gatherable", StringComparison.OrdinalIgnoreCase) &&
string.IsNullOrWhiteSpace(rule.ItemKey)))
return BadRequest("gatherable objectSpawnRules require itemKey");
return null;
}
private static BiomeDefinition BuildBiomeDefinition(string biomeKey, UpsertBiomeDefinitionRequest req)
{
var normalizedBiomeKey = NormalizeBiomeKey(biomeKey);
return new BiomeDefinition
{
BiomeKey = normalizedBiomeKey,
ContinuationWeight = req.ContinuationWeight,
TransitionWeights = req.TransitionWeights.Select(weight => new BiomeTransitionWeight
{
TargetBiomeKey = NormalizeBiomeKey(weight.TargetBiomeKey),
Weight = weight.Weight
}).ToList(),
ObjectSpawnRules = req.ObjectSpawnRules.Select(rule => new BiomeObjectSpawnRule
{
ResultType = rule.ResultType.Trim().ToLowerInvariant(),
ItemKey = string.IsNullOrWhiteSpace(rule.ItemKey) ? null : rule.ItemKey.Trim().ToLowerInvariant(),
ObjectKey = string.IsNullOrWhiteSpace(rule.ObjectKey) ? null : rule.ObjectKey.Trim(),
DisplayName = string.IsNullOrWhiteSpace(rule.DisplayName) ? null : rule.DisplayName.Trim(),
RemainingQuantity = rule.RemainingQuantity,
GatherQuantity = rule.GatherQuantity,
Weight = rule.Weight
}).ToList(),
UpdatedUtc = DateTime.UtcNow
};
}
private static string NormalizeBiomeKey(string biomeKey) => biomeKey.Trim().ToLowerInvariant();
private async Task PopulateFloorInventoriesAsync(VisibleLocationWindowResponse result)
{
var locationIds = result.Locations
.Where(location => !string.IsNullOrWhiteSpace(location.Id))
.Select(location => location.Id!)
.Distinct(StringComparer.Ordinal)
.ToList();
if (locationIds.Count == 0)
return;
var inventoryBaseUrl = (_configuration["Services:InventoryApiBaseUrl"] ?? "http://localhost:5003").TrimEnd('/');
var internalApiKey = (_configuration["InternalApi:Key"] ?? _configuration["Jwt:Key"] ?? string.Empty).Trim();
var body = JsonSerializer.Serialize(new { ownerIds = locationIds });
var client = _httpClientFactory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Post, $"{inventoryBaseUrl}/api/inventory/internal/by-owner/location");
request.Content = new StringContent(body, Encoding.UTF8, "application/json");
request.Headers.Add("X-Internal-Api-Key", internalApiKey);
using var response = await client.SendAsync(request);
var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning(
"Failed to load batched floor inventories from InventoryApi. Status={StatusCode} Body={Body}",
(int)response.StatusCode,
responseBody);
return;
}
var parsed = JsonSerializer.Deserialize<List<OwnerInventorySummaryEnvelope>>(
responseBody,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? [];
var byOwnerId = parsed.ToDictionary(entry => entry.OwnerId, entry => entry.Items, StringComparer.Ordinal);
foreach (var location in result.Locations)
{
if (string.IsNullOrWhiteSpace(location.Id))
continue;
location.FloorItems = byOwnerId.GetValueOrDefault(location.Id!, []);
}
}
private sealed class OwnerInventorySummaryEnvelope
{
public string OwnerId { get; set; } = string.Empty;
public List<FloorInventoryItemResponse> Items { get; set; } = [];
}
}