diff --git a/microservices/CharacterApi/Controllers/CharactersController.cs b/microservices/CharacterApi/Controllers/CharactersController.cs index 1e5770a..8b3c8a7 100644 --- a/microservices/CharacterApi/Controllers/CharactersController.cs +++ b/microservices/CharacterApi/Controllers/CharactersController.cs @@ -68,11 +68,28 @@ public class CharactersController : ControllerBase var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); - + var characters = await _characters.GetForOwnerAsync(userId); return Ok(characters); } + [HttpPost("internal/reset-coords")] + public async Task ResetCoordsInternal([FromBody] InternalResetCharactersToCoordRequest 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.Coord is null) + return BadRequest("coord required"); + + var updatedCharacterCount = await _characters.ResetAllCoordsAsync(req.Coord); + return Ok(new InternalResetCharactersToCoordResponse + { + UpdatedCharacterCount = updatedCharacterCount + }); + } + [HttpGet("{id}/visible-locations")] [Authorize(Roles = "USER,SUPER")] public async Task VisibleLocations(string id) diff --git a/microservices/CharacterApi/Models/InternalResetCharactersToCoordRequest.cs b/microservices/CharacterApi/Models/InternalResetCharactersToCoordRequest.cs new file mode 100644 index 0000000..ce56b27 --- /dev/null +++ b/microservices/CharacterApi/Models/InternalResetCharactersToCoordRequest.cs @@ -0,0 +1,6 @@ +namespace CharacterApi.Models; + +public class InternalResetCharactersToCoordRequest +{ + public required Coord Coord { get; set; } +} diff --git a/microservices/CharacterApi/Models/InternalResetCharactersToCoordResponse.cs b/microservices/CharacterApi/Models/InternalResetCharactersToCoordResponse.cs new file mode 100644 index 0000000..31a702c --- /dev/null +++ b/microservices/CharacterApi/Models/InternalResetCharactersToCoordResponse.cs @@ -0,0 +1,6 @@ +namespace CharacterApi.Models; + +public class InternalResetCharactersToCoordResponse +{ + public int UpdatedCharacterCount { get; set; } +} diff --git a/microservices/CharacterApi/Services/CharacterStore.cs b/microservices/CharacterApi/Services/CharacterStore.cs index 85c80be..107569e 100644 --- a/microservices/CharacterApi/Services/CharacterStore.cs +++ b/microservices/CharacterApi/Services/CharacterStore.cs @@ -49,6 +49,15 @@ public class CharacterStore return result.ModifiedCount > 0 || result.MatchedCount > 0; } + public async Task ResetAllCoordsAsync(Coord coord) + { + var update = Builders.Update + .Set(c => c.Coord, coord) + .Set(c => c.LastSeenUtc, DateTime.UtcNow); + var result = await _col.UpdateManyAsync(Builders.Filter.Empty, update); + return (int)result.MatchedCount; + } + public async Task> GetVisibleOthersAsync(string characterId, int x, int y, int radius, DateTime onlineSinceUtc) { var characters = await _col.Find(c => diff --git a/microservices/InventoryApi/Controllers/InventoryController.cs b/microservices/InventoryApi/Controllers/InventoryController.cs index 0955649..9e73407 100644 --- a/microservices/InventoryApi/Controllers/InventoryController.cs +++ b/microservices/InventoryApi/Controllers/InventoryController.cs @@ -48,6 +48,28 @@ public class InventoryController : ControllerBase return Ok(response); } + [HttpPost("internal/location-owner/reassign")] + public async Task ReassignLocationOwnerInventoryInternal([FromBody] InternalReassignLocationInventoryRequest req) + { + var configuredKey = (HttpContext.RequestServices.GetRequiredService()["InternalApi:Key"] + ?? HttpContext.RequestServices.GetRequiredService()["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 (string.IsNullOrWhiteSpace(req.ToOwnerId)) + return BadRequest("toOwnerId required"); + + var result = await _inventory.ReassignLocationInventoryOwnersAsync(req.FromOwnerIds, req.ToOwnerId); + if (!result.TargetLocationExists) + return NotFound("Target location not found"); + + return Ok(new InternalReassignLocationInventoryResponse + { + ReassignedItemCount = result.ReassignedItemCount + }); + } + [HttpGet("item-definitions")] [Authorize(Roles = "USER,SUPER")] public async Task ListItemDefinitions() diff --git a/microservices/InventoryApi/Models/InternalReassignLocationInventoryRequest.cs b/microservices/InventoryApi/Models/InternalReassignLocationInventoryRequest.cs new file mode 100644 index 0000000..02c7ea6 --- /dev/null +++ b/microservices/InventoryApi/Models/InternalReassignLocationInventoryRequest.cs @@ -0,0 +1,8 @@ +namespace InventoryApi.Models; + +public class InternalReassignLocationInventoryRequest +{ + public List FromOwnerIds { get; set; } = []; + + public string ToOwnerId { get; set; } = string.Empty; +} diff --git a/microservices/InventoryApi/Models/InternalReassignLocationInventoryResponse.cs b/microservices/InventoryApi/Models/InternalReassignLocationInventoryResponse.cs new file mode 100644 index 0000000..4181435 --- /dev/null +++ b/microservices/InventoryApi/Models/InternalReassignLocationInventoryResponse.cs @@ -0,0 +1,6 @@ +namespace InventoryApi.Models; + +public class InternalReassignLocationInventoryResponse +{ + public int ReassignedItemCount { get; set; } +} diff --git a/microservices/InventoryApi/Services/InventoryStore.cs b/microservices/InventoryApi/Services/InventoryStore.cs index 4bd318d..c9926e4 100644 --- a/microservices/InventoryApi/Services/InventoryStore.cs +++ b/microservices/InventoryApi/Services/InventoryStore.cs @@ -22,6 +22,7 @@ public class InventoryStore private readonly string _dbName; public sealed record GrantResult(List Items, int RequestedQuantity, int GrantedQuantity, int OverflowQuantity); + public sealed record ReassignLocationInventoryResult(bool TargetLocationExists, int ReassignedItemCount); public InventoryStore(IConfiguration cfg) { @@ -114,6 +115,35 @@ public class InventoryStore .ToDictionary(group => group.Key, group => group.ToList(), StringComparer.Ordinal); } + public async Task ReassignLocationInventoryOwnersAsync(IEnumerable fromOwnerIds, string toOwnerId) + { + var normalizedToOwnerId = toOwnerId.Trim(); + var targetExists = await _locations.Find(location => location.Id == normalizedToOwnerId).AnyAsync(); + if (!targetExists) + return new ReassignLocationInventoryResult(false, 0); + + var sourceOwnerIds = fromOwnerIds + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Select(id => id.Trim()) + .Where(id => !string.Equals(id, normalizedToOwnerId, StringComparison.Ordinal)) + .Distinct(StringComparer.Ordinal) + .ToList(); + if (sourceOwnerIds.Count == 0) + return new ReassignLocationInventoryResult(true, 0); + + var filter = Builders.Filter.And( + Builders.Filter.Eq(item => item.OwnerType, LocationOwnerType), + Builders.Filter.In(item => item.OwnerId, sourceOwnerIds)); + var update = Builders.Update + .Set(item => item.OwnerId, normalizedToOwnerId) + .Set(item => item.OwnerUserId, null) + .Unset(item => item.Slot) + .Unset(item => item.EquippedSlot) + .Set(item => item.UpdatedUtc, DateTime.UtcNow); + var result = await _items.UpdateManyAsync(filter, update); + return new ReassignLocationInventoryResult(true, (int)result.ModifiedCount); + } + public Task> ListItemDefinitionsAsync() => _definitions.Find(Builders.Filter.Empty).SortBy(d => d.ItemKey).ToListAsync(); diff --git a/microservices/LocationsApi/Controllers/LocationsController.cs b/microservices/LocationsApi/Controllers/LocationsController.cs index 8aa35af..f79db79 100644 --- a/microservices/LocationsApi/Controllers/LocationsController.cs +++ b/microservices/LocationsApi/Controllers/LocationsController.cs @@ -14,6 +14,8 @@ namespace LocationsApi.Controllers; [Route("api/[controller]")] public class LocationsController : ControllerBase { + private const string ResetWorldConfirmationPhrase = "RESET WORLD TO ORIGIN"; + private static readonly Coord OriginCoord = new() { X = 0, Y = 0 }; private readonly LocationStore _locations; private readonly IHttpClientFactory _httpClientFactory; private readonly IConfiguration _configuration; @@ -131,6 +133,63 @@ public class LocationsController : ControllerBase return Ok(locations); } + [HttpPost("reset-world")] + [Authorize(Roles = "SUPER")] + public async Task ResetWorld([FromBody] ResetWorldRequest req) + { + if (!req.ConfirmDeleteAllNonOriginLocations) + return BadRequest("confirmDeleteAllNonOriginLocations must be true"); + if (!string.Equals(req.ConfirmationPhrase?.Trim(), ResetWorldConfirmationPhrase, StringComparison.Ordinal)) + return BadRequest($"confirmationPhrase must exactly match '{ResetWorldConfirmationPhrase}'"); + + try + { + var limbo = await _locations.EnsureLimboLocationAsync(); + if (string.IsNullOrWhiteSpace(limbo.Id)) + return StatusCode(StatusCodes.Status500InternalServerError, "Limbo location was created without an id"); + + var movedCharacterCount = await ResetCharactersToOriginAsync(); + var locationIdsToReassign = await _locations.GetLocationIdsForWorldResetAsync(limbo.Id); + var reassignedInventoryItemCount = await ReassignLocationInventoryToLimboAsync(locationIdsToReassign, limbo.Id); + var result = await _locations.ResetWorldToOriginAsync(limbo.Id); + result.MovedCharacterCount = movedCharacterCount; + result.ReassignedInventoryItemCount = reassignedInventoryItemCount; + _logger.LogWarning( + "World reset to origin by user {UserId}. DeletedLocationCount={DeletedLocationCount} MovedCharacterCount={MovedCharacterCount} ReassignedInventoryItemCount={ReassignedInventoryItemCount}", + User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown", + result.DeletedLocationCount, + result.MovedCharacterCount, + result.ReassignedInventoryItemCount); + return Ok(result); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "World reset failed because biome definitions are unavailable."); + return Conflict("Cannot reset world because no biome definitions exist."); + } + catch (InternalServiceCallException ex) + { + _logger.LogError( + ex, + "World reset failed while calling {ServiceName}. StatusCode={StatusCode}", + ex.ServiceName, + ex.StatusCode); + return StatusCode(ex.StatusCode, ex.ResponseBody); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "World reset failed while reaching a dependent internal service."); + return StatusCode(StatusCodes.Status502BadGateway, new + { + type = "https://httpstatuses.com/502", + title = "Bad Gateway", + status = 502, + detail = "Failed to reach a dependent internal service during world reset.", + traceId = HttpContext.TraceIdentifier + }); + } + } + [HttpDelete("{id}")] [Authorize(Roles = "SUPER")] public async Task Delete(string id) @@ -435,4 +494,83 @@ public class LocationsController : ControllerBase public List Items { get; set; } = []; } + + private async Task ResetCharactersToOriginAsync() + { + var characterBaseUrl = (_configuration["Services:CharacterApiBaseUrl"] ?? "http://localhost:50785").TrimEnd('/'); + var internalApiKey = (_configuration["InternalApi:Key"] ?? _configuration["Jwt:Key"] ?? string.Empty).Trim(); + var body = JsonSerializer.Serialize(new { coord = OriginCoord }); + + var client = _httpClientFactory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, $"{characterBaseUrl}/api/characters/internal/reset-coords"); + 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) + throw new InternalServiceCallException("CharacterApi", (int)response.StatusCode, responseBody); + + if (string.IsNullOrWhiteSpace(responseBody)) + return 0; + + using var json = JsonDocument.Parse(responseBody); + return json.RootElement.TryGetProperty("updatedCharacterCount", out var countElement) && countElement.ValueKind == JsonValueKind.Number + ? countElement.GetInt32() + : 0; + } + + private async Task ReassignLocationInventoryToLimboAsync(IEnumerable fromOwnerIds, string toOwnerId) + { + var ownerIds = fromOwnerIds + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Select(id => id.Trim()) + .Distinct(StringComparer.Ordinal) + .ToList(); + if (ownerIds.Count == 0) + return 0; + + 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 + { + fromOwnerIds = ownerIds, + toOwnerId + }); + + var client = _httpClientFactory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, $"{inventoryBaseUrl}/api/inventory/internal/location-owner/reassign"); + 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) + throw new InternalServiceCallException("InventoryApi", (int)response.StatusCode, responseBody); + + if (string.IsNullOrWhiteSpace(responseBody)) + return 0; + + using var json = JsonDocument.Parse(responseBody); + return json.RootElement.TryGetProperty("reassignedItemCount", out var countElement) && countElement.ValueKind == JsonValueKind.Number + ? countElement.GetInt32() + : 0; + } + + private sealed class InternalServiceCallException : Exception + { + public InternalServiceCallException(string serviceName, int statusCode, string responseBody) + : base($"{serviceName} returned {statusCode}") + { + ServiceName = serviceName; + StatusCode = statusCode; + ResponseBody = responseBody; + } + + public string ServiceName { get; } + + public int StatusCode { get; } + + public string ResponseBody { get; } + } } diff --git a/microservices/LocationsApi/DOCUMENTS.md b/microservices/LocationsApi/DOCUMENTS.md index 5f08a25..6e146e5 100644 --- a/microservices/LocationsApi/DOCUMENTS.md +++ b/microservices/LocationsApi/DOCUMENTS.md @@ -21,6 +21,14 @@ Inbound JSON documents } ``` `coord` cannot be updated. +- ResetWorldRequest (`POST /api/locations/reset-world`) + ```json + { + "confirmDeleteAllNonOriginLocations": true, + "confirmationPhrase": "RESET WORLD TO ORIGIN" + } + ``` + Both fields are required for the request to be accepted. - GatherResourceRequest (`POST /api/locations/{id}/gather`) ```json { @@ -63,3 +71,40 @@ Outbound JSON documents "inventoryResponseJson": "string (raw InventoryApi response JSON)" } ``` +- ResetWorldResponse (`POST /api/locations/reset-world`) + ```json + { + "deletedLocationCount": 42, + "remainingLocationCount": 2, + "movedCharacterCount": 5, + "reassignedInventoryItemCount": 19, + "origin": { + "id": "string (ObjectId)", + "name": "Origin", + "coord": { + "x": 0, + "y": 0 + }, + "biomeKey": "plains", + "elevation": 0, + "resources": [], + "locationObject": null, + "locationObjectResolved": true, + "createdUtc": "string (ISO-8601 datetime)" + }, + "limbo": { + "id": "string (ObjectId)", + "name": "World Limbo", + "coord": { + "x": -2000000000, + "y": -2000000000 + }, + "biomeKey": "plains", + "elevation": 0, + "resources": [], + "locationObject": null, + "locationObjectResolved": true, + "createdUtc": "string (ISO-8601 datetime)" + } + } + ``` diff --git a/microservices/LocationsApi/Models/ResetWorldRequest.cs b/microservices/LocationsApi/Models/ResetWorldRequest.cs new file mode 100644 index 0000000..01aeff0 --- /dev/null +++ b/microservices/LocationsApi/Models/ResetWorldRequest.cs @@ -0,0 +1,8 @@ +namespace LocationsApi.Models; + +public class ResetWorldRequest +{ + public bool ConfirmDeleteAllNonOriginLocations { get; set; } + + public string ConfirmationPhrase { get; set; } = string.Empty; +} diff --git a/microservices/LocationsApi/Models/ResetWorldResponse.cs b/microservices/LocationsApi/Models/ResetWorldResponse.cs new file mode 100644 index 0000000..550cce6 --- /dev/null +++ b/microservices/LocationsApi/Models/ResetWorldResponse.cs @@ -0,0 +1,16 @@ +namespace LocationsApi.Models; + +public class ResetWorldResponse +{ + public int DeletedLocationCount { get; set; } + + public int RemainingLocationCount { get; set; } + + public int MovedCharacterCount { get; set; } + + public int ReassignedInventoryItemCount { get; set; } + + public required Location Origin { get; set; } + + public required Location Limbo { get; set; } +} diff --git a/microservices/LocationsApi/README.md b/microservices/LocationsApi/README.md index cdbde9a..b138b57 100644 --- a/microservices/LocationsApi/README.md +++ b/microservices/LocationsApi/README.md @@ -5,6 +5,7 @@ See `DOCUMENTS.md` for request payloads and stored document shapes. ## Endpoints - `POST /api/locations` Create a location with a unique coord pair (SUPER only). +- `POST /api/locations/reset-world` Move all characters to origin, move floor inventory from reset locations into a hidden limbo location, then recreate the world with a fresh origin tile plus that limbo location. Requires an explicit confirmation request body (SUPER only). - `GET /api/locations` List all locations (SUPER only). - `DELETE /api/locations/{id}` Delete a location (SUPER only). - `PUT /api/locations/{id}` Update a location name (SUPER only). diff --git a/microservices/LocationsApi/Services/LocationStore.cs b/microservices/LocationsApi/Services/LocationStore.cs index 9d474e3..4c06a63 100644 --- a/microservices/LocationsApi/Services/LocationStore.cs +++ b/microservices/LocationsApi/Services/LocationStore.cs @@ -11,6 +11,9 @@ public class LocationStore private readonly IMongoCollection _characters; private readonly IMongoCollection _biomeDefinitions; private const string CoordIndexName = "coord_x_1_coord_y_1"; + private const string LimboLocationName = "World Limbo"; + private const int LimboCoordX = -2000000000; + private const int LimboCoordY = -2000000000; public LocationStore(IConfiguration cfg) { @@ -339,6 +342,60 @@ public class LocationStore return hasUpper || (hasLower && !(keyDoc.Contains("coord.x") && keyDoc.Contains("coord.y"))); } + public async Task EnsureLimboLocationAsync() + { + var filter = Builders.Filter.And( + Builders.Filter.Eq(location => location.Coord.X, LimboCoordX), + Builders.Filter.Eq(location => location.Coord.Y, LimboCoordY)); + var existing = await _col.Find(filter).FirstOrDefaultAsync(); + if (existing is not null) + return existing; + + var biomeDefinitions = await LoadBiomeDefinitionsAsync(); + var limbo = BuildLimboLocation(biomeDefinitions); + try + { + await _col.InsertOneAsync(limbo); + return limbo; + } + catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) + { + return await _col.Find(filter).FirstOrDefaultAsync() + ?? throw new InvalidOperationException("Limbo location was created concurrently but could not be reloaded."); + } + } + + public async Task> GetLocationIdsForWorldResetAsync(string preservedLocationId) + { + var filter = Builders.Filter.Ne(location => location.Id, preservedLocationId); + var locations = await _col.Find(filter).ToListAsync(); + return locations + .Where(location => !string.IsNullOrWhiteSpace(location.Id)) + .Select(location => location.Id!) + .ToList(); + } + + public async Task ResetWorldToOriginAsync(string preservedLocationId) + { + var biomeDefinitions = await LoadBiomeDefinitionsAsync(); + var deleteFilter = Builders.Filter.Ne(location => location.Id, preservedLocationId); + var deleteResult = await _col.DeleteManyAsync(deleteFilter); + + var origin = BuildOriginLocation(biomeDefinitions); + await _col.InsertOneAsync(origin); + var limbo = await _col.Find(location => location.Id == preservedLocationId).FirstOrDefaultAsync() + ?? throw new InvalidOperationException("Limbo location missing after world reset."); + var remainingLocationCount = await _col.CountDocumentsAsync(Builders.Filter.Empty); + + return new ResetWorldResponse + { + DeletedLocationCount = (int)deleteResult.DeletedCount, + RemainingLocationCount = (int)remainingLocationCount, + Origin = origin, + Limbo = limbo + }; + } + private void EnsureOriginLocation() { var biomeDefinitions = LoadBiomeDefinitions(); @@ -356,21 +413,12 @@ public class LocationStore if (existing is not null) return; - var origin = new Location + var origin = BuildOriginLocation(biomeDefinitions, originBiomeKey); + + try { - Name = "Origin", - Coord = new Coord { X = 0, Y = 0 }, - BiomeKey = originBiomeKey, - Elevation = 0, - LocationObject = CreateLocationObjectForBiome(biomeDefinitions, originBiomeKey, 0, 0), - LocationObjectResolved = true, - CreatedUtc = DateTime.UtcNow - }; - - try - { - _col.InsertOne(origin); - } + _col.InsertOne(origin); + } catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) { // Another instance seeded it first. @@ -461,6 +509,44 @@ public class LocationStore } } + private static Location BuildOriginLocation(IReadOnlyList biomeDefinitions, string? originBiomeKey = null) + { + var resolvedOriginBiomeKey = string.IsNullOrWhiteSpace(originBiomeKey) + ? biomeDefinitions.Any(definition => definition.BiomeKey == "plains") + ? "plains" + : biomeDefinitions[0].BiomeKey + : originBiomeKey; + + return new Location + { + Name = "Origin", + Coord = new Coord { X = 0, Y = 0 }, + BiomeKey = resolvedOriginBiomeKey, + Elevation = 0, + LocationObject = CreateLocationObjectForBiome(biomeDefinitions, resolvedOriginBiomeKey, 0, 0), + LocationObjectResolved = true, + CreatedUtc = DateTime.UtcNow + }; + } + + private static Location BuildLimboLocation(IReadOnlyList biomeDefinitions) + { + var limboBiomeKey = biomeDefinitions.Any(definition => definition.BiomeKey == "plains") + ? "plains" + : biomeDefinitions[0].BiomeKey; + + return new Location + { + Name = LimboLocationName, + Coord = new Coord { X = LimboCoordX, Y = LimboCoordY }, + BiomeKey = limboBiomeKey, + Elevation = 0, + LocationObject = null, + LocationObjectResolved = true, + CreatedUtc = DateTime.UtcNow + }; + } + private async Task EnsureLocationMetadataAsync(Location location) { var locationId = location.Id ?? string.Empty;