Kill switch
Some checks failed
Deploy Promiscuity Auth API / deploy (push) Successful in 47s
Deploy Promiscuity Crafting API / deploy (push) Has been cancelled
Deploy Promiscuity Inventory API / deploy (push) Has been cancelled
Deploy Promiscuity Locations API / deploy (push) Has been cancelled
Deploy Promiscuity Mail API / deploy (push) Has been cancelled
Deploy Promiscuity World API / deploy (push) Has been cancelled
Deploy Promiscuity Character API / deploy (push) Has been cancelled
k8s smoke test / test (push) Has been cancelled
Some checks failed
Deploy Promiscuity Auth API / deploy (push) Successful in 47s
Deploy Promiscuity Crafting API / deploy (push) Has been cancelled
Deploy Promiscuity Inventory API / deploy (push) Has been cancelled
Deploy Promiscuity Locations API / deploy (push) Has been cancelled
Deploy Promiscuity Mail API / deploy (push) Has been cancelled
Deploy Promiscuity World API / deploy (push) Has been cancelled
Deploy Promiscuity Character API / deploy (push) Has been cancelled
k8s smoke test / test (push) Has been cancelled
This commit is contained in:
parent
bed8e91bc0
commit
c718fb343d
@ -68,11 +68,28 @@ public class CharactersController : ControllerBase
|
|||||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
if (string.IsNullOrWhiteSpace(userId))
|
if (string.IsNullOrWhiteSpace(userId))
|
||||||
return Unauthorized();
|
return Unauthorized();
|
||||||
|
|
||||||
var characters = await _characters.GetForOwnerAsync(userId);
|
var characters = await _characters.GetForOwnerAsync(userId);
|
||||||
return Ok(characters);
|
return Ok(characters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("internal/reset-coords")]
|
||||||
|
public async Task<IActionResult> 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")]
|
[HttpGet("{id}/visible-locations")]
|
||||||
[Authorize(Roles = "USER,SUPER")]
|
[Authorize(Roles = "USER,SUPER")]
|
||||||
public async Task<IActionResult> VisibleLocations(string id)
|
public async Task<IActionResult> VisibleLocations(string id)
|
||||||
|
|||||||
@ -0,0 +1,6 @@
|
|||||||
|
namespace CharacterApi.Models;
|
||||||
|
|
||||||
|
public class InternalResetCharactersToCoordRequest
|
||||||
|
{
|
||||||
|
public required Coord Coord { get; set; }
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
namespace CharacterApi.Models;
|
||||||
|
|
||||||
|
public class InternalResetCharactersToCoordResponse
|
||||||
|
{
|
||||||
|
public int UpdatedCharacterCount { get; set; }
|
||||||
|
}
|
||||||
@ -49,6 +49,15 @@ public class CharacterStore
|
|||||||
return result.ModifiedCount > 0 || result.MatchedCount > 0;
|
return result.ModifiedCount > 0 || result.MatchedCount > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<int> ResetAllCoordsAsync(Coord coord)
|
||||||
|
{
|
||||||
|
var update = Builders<Character>.Update
|
||||||
|
.Set(c => c.Coord, coord)
|
||||||
|
.Set(c => c.LastSeenUtc, DateTime.UtcNow);
|
||||||
|
var result = await _col.UpdateManyAsync(Builders<Character>.Filter.Empty, update);
|
||||||
|
return (int)result.MatchedCount;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<VisibleCharacter>> GetVisibleOthersAsync(string characterId, int x, int y, int radius, DateTime onlineSinceUtc)
|
public async Task<List<VisibleCharacter>> GetVisibleOthersAsync(string characterId, int x, int y, int radius, DateTime onlineSinceUtc)
|
||||||
{
|
{
|
||||||
var characters = await _col.Find(c =>
|
var characters = await _col.Find(c =>
|
||||||
|
|||||||
@ -48,6 +48,28 @@ public class InventoryController : ControllerBase
|
|||||||
return Ok(response);
|
return Ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("internal/location-owner/reassign")]
|
||||||
|
public async Task<IActionResult> ReassignLocationOwnerInventoryInternal([FromBody] InternalReassignLocationInventoryRequest req)
|
||||||
|
{
|
||||||
|
var configuredKey = (HttpContext.RequestServices.GetRequiredService<IConfiguration>()["InternalApi:Key"]
|
||||||
|
?? HttpContext.RequestServices.GetRequiredService<IConfiguration>()["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")]
|
[HttpGet("item-definitions")]
|
||||||
[Authorize(Roles = "USER,SUPER")]
|
[Authorize(Roles = "USER,SUPER")]
|
||||||
public async Task<IActionResult> ListItemDefinitions()
|
public async Task<IActionResult> ListItemDefinitions()
|
||||||
|
|||||||
@ -0,0 +1,8 @@
|
|||||||
|
namespace InventoryApi.Models;
|
||||||
|
|
||||||
|
public class InternalReassignLocationInventoryRequest
|
||||||
|
{
|
||||||
|
public List<string> FromOwnerIds { get; set; } = [];
|
||||||
|
|
||||||
|
public string ToOwnerId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
namespace InventoryApi.Models;
|
||||||
|
|
||||||
|
public class InternalReassignLocationInventoryResponse
|
||||||
|
{
|
||||||
|
public int ReassignedItemCount { get; set; }
|
||||||
|
}
|
||||||
@ -22,6 +22,7 @@ public class InventoryStore
|
|||||||
private readonly string _dbName;
|
private readonly string _dbName;
|
||||||
|
|
||||||
public sealed record GrantResult(List<InventoryItem> Items, int RequestedQuantity, int GrantedQuantity, int OverflowQuantity);
|
public sealed record GrantResult(List<InventoryItem> Items, int RequestedQuantity, int GrantedQuantity, int OverflowQuantity);
|
||||||
|
public sealed record ReassignLocationInventoryResult(bool TargetLocationExists, int ReassignedItemCount);
|
||||||
|
|
||||||
public InventoryStore(IConfiguration cfg)
|
public InventoryStore(IConfiguration cfg)
|
||||||
{
|
{
|
||||||
@ -114,6 +115,35 @@ public class InventoryStore
|
|||||||
.ToDictionary(group => group.Key, group => group.ToList(), StringComparer.Ordinal);
|
.ToDictionary(group => group.Key, group => group.ToList(), StringComparer.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<ReassignLocationInventoryResult> ReassignLocationInventoryOwnersAsync(IEnumerable<string> 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<InventoryItem>.Filter.And(
|
||||||
|
Builders<InventoryItem>.Filter.Eq(item => item.OwnerType, LocationOwnerType),
|
||||||
|
Builders<InventoryItem>.Filter.In(item => item.OwnerId, sourceOwnerIds));
|
||||||
|
var update = Builders<InventoryItem>.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<List<ItemDefinition>> ListItemDefinitionsAsync() =>
|
public Task<List<ItemDefinition>> ListItemDefinitionsAsync() =>
|
||||||
_definitions.Find(Builders<ItemDefinition>.Filter.Empty).SortBy(d => d.ItemKey).ToListAsync();
|
_definitions.Find(Builders<ItemDefinition>.Filter.Empty).SortBy(d => d.ItemKey).ToListAsync();
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,8 @@ namespace LocationsApi.Controllers;
|
|||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public class LocationsController : ControllerBase
|
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 LocationStore _locations;
|
||||||
private readonly IHttpClientFactory _httpClientFactory;
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
@ -131,6 +133,63 @@ public class LocationsController : ControllerBase
|
|||||||
return Ok(locations);
|
return Ok(locations);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("reset-world")]
|
||||||
|
[Authorize(Roles = "SUPER")]
|
||||||
|
public async Task<IActionResult> 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}")]
|
[HttpDelete("{id}")]
|
||||||
[Authorize(Roles = "SUPER")]
|
[Authorize(Roles = "SUPER")]
|
||||||
public async Task<IActionResult> Delete(string id)
|
public async Task<IActionResult> Delete(string id)
|
||||||
@ -435,4 +494,83 @@ public class LocationsController : ControllerBase
|
|||||||
|
|
||||||
public List<FloorInventoryItemResponse> Items { get; set; } = [];
|
public List<FloorInventoryItemResponse> Items { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<int> 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<int> ReassignLocationInventoryToLimboAsync(IEnumerable<string> 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; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,14 @@ Inbound JSON documents
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
`coord` cannot be updated.
|
`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`)
|
- GatherResourceRequest (`POST /api/locations/{id}/gather`)
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@ -63,3 +71,40 @@ Outbound JSON documents
|
|||||||
"inventoryResponseJson": "string (raw InventoryApi response JSON)"
|
"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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
8
microservices/LocationsApi/Models/ResetWorldRequest.cs
Normal file
8
microservices/LocationsApi/Models/ResetWorldRequest.cs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
namespace LocationsApi.Models;
|
||||||
|
|
||||||
|
public class ResetWorldRequest
|
||||||
|
{
|
||||||
|
public bool ConfirmDeleteAllNonOriginLocations { get; set; }
|
||||||
|
|
||||||
|
public string ConfirmationPhrase { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
16
microservices/LocationsApi/Models/ResetWorldResponse.cs
Normal file
16
microservices/LocationsApi/Models/ResetWorldResponse.cs
Normal file
@ -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; }
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ See `DOCUMENTS.md` for request payloads and stored document shapes.
|
|||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
- `POST /api/locations` Create a location with a unique coord pair (SUPER only).
|
- `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).
|
- `GET /api/locations` List all locations (SUPER only).
|
||||||
- `DELETE /api/locations/{id}` Delete a location (SUPER only).
|
- `DELETE /api/locations/{id}` Delete a location (SUPER only).
|
||||||
- `PUT /api/locations/{id}` Update a location name (SUPER only).
|
- `PUT /api/locations/{id}` Update a location name (SUPER only).
|
||||||
|
|||||||
@ -11,6 +11,9 @@ public class LocationStore
|
|||||||
private readonly IMongoCollection<CharacterDocument> _characters;
|
private readonly IMongoCollection<CharacterDocument> _characters;
|
||||||
private readonly IMongoCollection<BiomeDefinition> _biomeDefinitions;
|
private readonly IMongoCollection<BiomeDefinition> _biomeDefinitions;
|
||||||
private const string CoordIndexName = "coord_x_1_coord_y_1";
|
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)
|
public LocationStore(IConfiguration cfg)
|
||||||
{
|
{
|
||||||
@ -339,6 +342,60 @@ public class LocationStore
|
|||||||
return hasUpper || (hasLower && !(keyDoc.Contains("coord.x") && keyDoc.Contains("coord.y")));
|
return hasUpper || (hasLower && !(keyDoc.Contains("coord.x") && keyDoc.Contains("coord.y")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Location> EnsureLimboLocationAsync()
|
||||||
|
{
|
||||||
|
var filter = Builders<Location>.Filter.And(
|
||||||
|
Builders<Location>.Filter.Eq(location => location.Coord.X, LimboCoordX),
|
||||||
|
Builders<Location>.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<List<string>> GetLocationIdsForWorldResetAsync(string preservedLocationId)
|
||||||
|
{
|
||||||
|
var filter = Builders<Location>.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<ResetWorldResponse> ResetWorldToOriginAsync(string preservedLocationId)
|
||||||
|
{
|
||||||
|
var biomeDefinitions = await LoadBiomeDefinitionsAsync();
|
||||||
|
var deleteFilter = Builders<Location>.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<Location>.Filter.Empty);
|
||||||
|
|
||||||
|
return new ResetWorldResponse
|
||||||
|
{
|
||||||
|
DeletedLocationCount = (int)deleteResult.DeletedCount,
|
||||||
|
RemainingLocationCount = (int)remainingLocationCount,
|
||||||
|
Origin = origin,
|
||||||
|
Limbo = limbo
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private void EnsureOriginLocation()
|
private void EnsureOriginLocation()
|
||||||
{
|
{
|
||||||
var biomeDefinitions = LoadBiomeDefinitions();
|
var biomeDefinitions = LoadBiomeDefinitions();
|
||||||
@ -356,21 +413,12 @@ public class LocationStore
|
|||||||
if (existing is not null)
|
if (existing is not null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var origin = new Location
|
var origin = BuildOriginLocation(biomeDefinitions, originBiomeKey);
|
||||||
|
|
||||||
|
try
|
||||||
{
|
{
|
||||||
Name = "Origin",
|
_col.InsertOne(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);
|
|
||||||
}
|
|
||||||
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||||
{
|
{
|
||||||
// Another instance seeded it first.
|
// Another instance seeded it first.
|
||||||
@ -461,6 +509,44 @@ public class LocationStore
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Location BuildOriginLocation(IReadOnlyList<BiomeDefinition> 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<BiomeDefinition> 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<Location> EnsureLocationMetadataAsync(Location location)
|
private async Task<Location> EnsureLocationMetadataAsync(Location location)
|
||||||
{
|
{
|
||||||
var locationId = location.Id ?? string.Empty;
|
var locationId = location.Id ?? string.Empty;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user