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

This commit is contained in:
Zeeshaun 2026-04-01 20:22:05 +00:00
parent bed8e91bc0
commit c718fb343d
14 changed files with 413 additions and 15 deletions

View File

@ -73,6 +73,23 @@ public class CharactersController : ControllerBase
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")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> VisibleLocations(string id)

View File

@ -0,0 +1,6 @@
namespace CharacterApi.Models;
public class InternalResetCharactersToCoordRequest
{
public required Coord Coord { get; set; }
}

View File

@ -0,0 +1,6 @@
namespace CharacterApi.Models;
public class InternalResetCharactersToCoordResponse
{
public int UpdatedCharacterCount { get; set; }
}

View File

@ -49,6 +49,15 @@ public class CharacterStore
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)
{
var characters = await _col.Find(c =>

View File

@ -48,6 +48,28 @@ public class InventoryController : ControllerBase
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")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> ListItemDefinitions()

View File

@ -0,0 +1,8 @@
namespace InventoryApi.Models;
public class InternalReassignLocationInventoryRequest
{
public List<string> FromOwnerIds { get; set; } = [];
public string ToOwnerId { get; set; } = string.Empty;
}

View File

@ -0,0 +1,6 @@
namespace InventoryApi.Models;
public class InternalReassignLocationInventoryResponse
{
public int ReassignedItemCount { get; set; }
}

View File

@ -22,6 +22,7 @@ public class InventoryStore
private readonly string _dbName;
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)
{
@ -114,6 +115,35 @@ public class InventoryStore
.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() =>
_definitions.Find(Builders<ItemDefinition>.Filter.Empty).SortBy(d => d.ItemKey).ToListAsync();

View File

@ -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<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}")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> Delete(string id)
@ -435,4 +494,83 @@ public class LocationsController : ControllerBase
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; }
}
}

View File

@ -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)"
}
}
```

View File

@ -0,0 +1,8 @@
namespace LocationsApi.Models;
public class ResetWorldRequest
{
public bool ConfirmDeleteAllNonOriginLocations { get; set; }
public string ConfirmationPhrase { get; set; } = string.Empty;
}

View 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; }
}

View File

@ -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).

View File

@ -11,6 +11,9 @@ public class LocationStore
private readonly IMongoCollection<CharacterDocument> _characters;
private readonly IMongoCollection<BiomeDefinition> _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<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()
{
var biomeDefinitions = LoadBiomeDefinitions();
@ -356,16 +413,7 @@ public class LocationStore
if (existing is not null)
return;
var origin = new Location
{
Name = "Origin",
Coord = new Coord { X = 0, Y = 0 },
BiomeKey = originBiomeKey,
Elevation = 0,
LocationObject = CreateLocationObjectForBiome(biomeDefinitions, originBiomeKey, 0, 0),
LocationObjectResolved = true,
CreatedUtc = DateTime.UtcNow
};
var origin = BuildOriginLocation(biomeDefinitions, originBiomeKey);
try
{
@ -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)
{
var locationId = location.Id ?? string.Empty;