All checks were successful
Deploy Promiscuity Auth API / deploy (push) Successful in 45s
Deploy Promiscuity Character API / deploy (push) Successful in 57s
Deploy Promiscuity Inventory API / deploy (push) Successful in 45s
Deploy Promiscuity Locations API / deploy (push) Successful in 58s
k8s smoke test / test (push) Successful in 8s
371 lines
15 KiB
C#
371 lines
15 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);
|
|
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();
|
|
}
|