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 _logger; public LocationsController(LocationStore locations, IHttpClientFactory httpClientFactory, IConfiguration configuration, ILogger logger) { _locations = locations; _httpClientFactory = httpClientFactory; _configuration = configuration; _logger = logger; } [HttpPost] [Authorize(Roles = "SUPER")] public async Task 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 ListBiomeDefinitions() { var definitions = await _locations.GetBiomeDefinitionsAsync(); return Ok(definitions); } [HttpGet("biome-definitions/{biomeKey}")] [Authorize(Roles = "SUPER")] public async Task 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 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 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 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 ListMine() { var locations = await _locations.GetAllAsync(); return Ok(locations); } [HttpDelete("{id}")] [Authorize(Roles = "SUPER")] public async Task Delete(string id) { var deleted = await _locations.DeleteAsync(id); if (!deleted) return NotFound(); return Ok("Deleted"); } [HttpPut("{id}")] [Authorize(Roles = "SUPER")] public async Task 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 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>( 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 Items { get; set; } = []; } }