diff --git a/microservices/CharacterApi/Controllers/CharactersController.cs b/microservices/CharacterApi/Controllers/CharactersController.cs index f9cc70a..8835ee0 100644 --- a/microservices/CharacterApi/Controllers/CharactersController.cs +++ b/microservices/CharacterApi/Controllers/CharactersController.cs @@ -1,8 +1,11 @@ -using CharacterApi.Models; -using CharacterApi.Services; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using System.Security.Claims; +using CharacterApi.Models; +using CharacterApi.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text; +using System.Text.Json; namespace CharacterApi.Controllers; @@ -11,11 +14,15 @@ namespace CharacterApi.Controllers; public class CharactersController : ControllerBase { private readonly CharacterStore _characters; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IConfiguration _configuration; private readonly ILogger _logger; - public CharactersController(CharacterStore characters, ILogger logger) + public CharactersController(CharacterStore characters, IHttpClientFactory httpClientFactory, IConfiguration configuration, ILogger logger) { _characters = characters; + _httpClientFactory = httpClientFactory; + _configuration = configuration; _logger = logger; } @@ -91,17 +98,70 @@ public class CharactersController : ControllerBase userId ); - var generation = await _characters.GetOrCreateVisibleLocationsAsync(character); - var locations = generation.Locations; + var locationsBaseUrl = (_configuration["Services:LocationsApiBaseUrl"] ?? "http://localhost:5002").TrimEnd('/'); + var internalApiKey = (_configuration["InternalApi:Key"] ?? _configuration["Jwt:Key"] ?? string.Empty).Trim(); + var body = JsonSerializer.Serialize(new + { + x = character.Coord.X, + y = character.Coord.Y, + radius = character.VisionRadius > 0 ? character.VisionRadius : 3 + }); - _logger.LogInformation( - "Visible locations resolved for character {CharacterId}: generated {GeneratedCount}, returned {ReturnedCount}", - character.Id, - generation.GeneratedCount, - locations.Count - ); + var client = _httpClientFactory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, $"{locationsBaseUrl}/api/locations/internal/visible-window"); + request.Content = new StringContent(body, Encoding.UTF8, "application/json"); + request.Headers.Add("X-Internal-Api-Key", internalApiKey); - return Ok(locations); + HttpResponseMessage response; + try + { + response = await client.SendAsync(request); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Failed to reach LocationsApi while resolving visible locations for character {CharacterId}", character.Id); + return StatusCode(StatusCodes.Status502BadGateway, new + { + type = "https://httpstatuses.com/502", + title = "Bad Gateway", + status = 502, + detail = $"Failed to reach LocationsApi at {locationsBaseUrl}.", + traceId = HttpContext.TraceIdentifier + }); + } + + using (response) + { + var responseBody = await response.Content.ReadAsStringAsync(); + if (!response.IsSuccessStatusCode) + return StatusCode((int)response.StatusCode, responseBody); + + var generation = JsonSerializer.Deserialize( + responseBody, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + if (generation is null) + { + return StatusCode(StatusCodes.Status502BadGateway, new + { + type = "https://httpstatuses.com/502", + title = "Bad Gateway", + status = 502, + detail = "LocationsApi returned an unreadable visible locations payload.", + traceId = HttpContext.TraceIdentifier + }); + } + + var locations = generation.Locations; + + _logger.LogInformation( + "Visible locations resolved for character {CharacterId}: generated {GeneratedCount}, returned {ReturnedCount}", + character.Id, + generation.GeneratedCount, + locations.Count + ); + + return Ok(locations); + } } [HttpPut("{id}/coord")] diff --git a/microservices/CharacterApi/Models/VisibleLocationWindowResponse.cs b/microservices/CharacterApi/Models/VisibleLocationWindowResponse.cs new file mode 100644 index 0000000..ff992a4 --- /dev/null +++ b/microservices/CharacterApi/Models/VisibleLocationWindowResponse.cs @@ -0,0 +1,8 @@ +namespace CharacterApi.Models; + +public class VisibleLocationWindowResponse +{ + public int GeneratedCount { get; set; } + + public List Locations { get; set; } = []; +} diff --git a/microservices/CharacterApi/Program.cs b/microservices/CharacterApi/Program.cs index 7440d00..32c6612 100644 --- a/microservices/CharacterApi/Program.cs +++ b/microservices/CharacterApi/Program.cs @@ -6,10 +6,11 @@ using Microsoft.OpenApi.Models; using System.Text; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddControllers(); - -// DI -builder.Services.AddSingleton(); +builder.Services.AddControllers(); + +// DI +builder.Services.AddSingleton(); +builder.Services.AddHttpClient(); // Swagger + JWT auth in Swagger builder.Services.AddEndpointsApiExplorer(); diff --git a/microservices/CharacterApi/Services/CharacterStore.cs b/microservices/CharacterApi/Services/CharacterStore.cs index 15eaf8e..7ff1d88 100644 --- a/microservices/CharacterApi/Services/CharacterStore.cs +++ b/microservices/CharacterApi/Services/CharacterStore.cs @@ -1,5 +1,4 @@ using CharacterApi.Models; -using MongoDB.Bson; using MongoDB.Driver; namespace CharacterApi.Services; @@ -7,51 +6,6 @@ namespace CharacterApi.Services; public class CharacterStore { private readonly IMongoCollection _col; - private readonly IMongoCollection _locations; - private const string CoordIndexName = "coord_x_1_coord_y_1"; - private const int WorldSeed = 1729; - private static readonly string[] BiomeOrder = ["plains", "forest", "wetlands", "rocky", "desert"]; - private static readonly Dictionary Biomes = new() - { - ["plains"] = new("plains", 3.0, new() - { - ["forest"] = 1.7, - ["wetlands"] = 0.9, - ["rocky"] = 0.8, - ["desert"] = 0.4 - }), - ["forest"] = new("forest", 3.4, new() - { - ["plains"] = 1.6, - ["wetlands"] = 1.3, - ["rocky"] = 0.5, - ["desert"] = 0.1 - }), - ["wetlands"] = new("wetlands", 3.1, new() - { - ["forest"] = 1.5, - ["plains"] = 1.1, - ["rocky"] = 0.2, - ["desert"] = 0.05 - }), - ["rocky"] = new("rocky", 3.0, new() - { - ["plains"] = 1.2, - ["forest"] = 0.6, - ["desert"] = 1.1, - ["wetlands"] = 0.1 - }), - ["desert"] = new("desert", 3.2, new() - { - ["rocky"] = 1.4, - ["plains"] = 0.8, - ["forest"] = 0.1, - ["wetlands"] = 0.05 - }) - }; - - public sealed record VisibleLocationResult(List Locations, int GeneratedCount); - private sealed record BiomeDefinition(string Key, double ContinuationWeight, Dictionary TransitionWeights); public CharacterStore(IConfiguration cfg) { @@ -60,8 +14,6 @@ public class CharacterStore var client = new MongoClient(cs); var db = client.GetDatabase(dbName); _col = db.GetCollection("Characters"); - _locations = db.GetCollection("Locations"); - EnsureLocationCoordIndexes(); var ownerIndex = Builders.IndexKeys.Ascending(c => c.OwnerUserId); _col.Indexes.CreateOne(new CreateIndexModel(ownerIndex)); @@ -83,370 +35,6 @@ public class CharacterStore return result.ModifiedCount > 0 || result.MatchedCount > 0; } - public Task> GetVisibleLocationsAsync(Character character) => - GetVisibleLocationsInternalAsync(character, ensureGenerated: false); - - public async Task GetOrCreateVisibleLocationsAsync(Character character) - { - var generatedCount = await EnsureVisibleLocationsExistAsync(character); - var locations = await GetVisibleLocationsInternalAsync(character, ensureGenerated: true); - return new VisibleLocationResult(locations, generatedCount); - } - - private async Task> GetVisibleLocationsInternalAsync(Character character, bool ensureGenerated) - { - var radius = character.VisionRadius > 0 ? character.VisionRadius : 3; - var minX = character.Coord.X - radius; - var maxX = character.Coord.X + radius; - var minY = character.Coord.Y - radius; - var maxY = character.Coord.Y + radius; - - var filter = Builders.Filter.And( - Builders.Filter.Gte("coord.x", minX), - Builders.Filter.Lte("coord.x", maxX), - Builders.Filter.Gte("coord.y", minY), - Builders.Filter.Lte("coord.y", maxY) - ); - - var documents = await _locations.Find(filter).ToListAsync(); - if (ensureGenerated) - { - foreach (var document in documents) - await BackfillLocationStateAsync(document); - documents = await _locations.Find(filter).ToListAsync(); - } - - return documents.Select(MapVisibleLocation).ToList(); - } - - private async Task EnsureVisibleLocationsExistAsync(Character character) - { - var radius = character.VisionRadius > 0 ? character.VisionRadius : 3; - var generatedCount = 0; - - for (var x = character.Coord.X - radius; x <= character.Coord.X + radius; x++) - { - for (var y = character.Coord.Y - radius; y <= character.Coord.Y + radius; y++) - { - if (await EnsureLocationStateAsync(x, y)) - generatedCount += 1; - } - } - - return generatedCount; - } - - private async Task EnsureLocationStateAsync(int x, int y) - { - var filter = Builders.Filter.And( - Builders.Filter.Eq("coord.x", x), - Builders.Filter.Eq("coord.y", y) - ); - - var existing = await _locations.Find(filter).FirstOrDefaultAsync(); - if (existing is not null) - { - await BackfillLocationStateAsync(existing); - return false; - } - - var biomeKey = await DetermineBiomeKeyAsync(x, y); - var update = Builders.Update - .SetOnInsert("_id", ObjectId.GenerateNewId()) - .SetOnInsert("name", DefaultLocationName(x, y)) - .SetOnInsert("coord", new BsonDocument - { - { "x", x }, - { "y", y } - }) - .SetOnInsert("biomeKey", biomeKey) - .SetOnInsert("locationObject", CreateLocationObjectValueForBiome(biomeKey, x, y)) - .SetOnInsert("locationObjectResolved", true) - .SetOnInsert("createdUtc", DateTime.UtcNow); - - try - { - var result = await _locations.UpdateOneAsync(filter, update, new UpdateOptions { IsUpsert = true }); - return result.UpsertedId is not null; - } - catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) - { - return false; - } - } - - private async Task BackfillLocationStateAsync(BsonDocument document) - { - var coord = document.GetValue("coord", new BsonDocument()).AsBsonDocument; - var x = coord.GetValue("x", 0).ToInt32(); - var y = coord.GetValue("y", 0).ToInt32(); - - var updates = new List>(); - var biomeKey = document.GetValue("biomeKey", BsonNull.Value).IsBsonNull - ? await DetermineBiomeKeyAsync(x, y) - : document.GetValue("biomeKey", "plains").AsString; - var objectResolved = document.TryGetValue("locationObjectResolved", out var resolvedValue) && - resolvedValue.ToBoolean(); - - if (!document.Contains("biomeKey")) - updates.Add(Builders.Update.Set("biomeKey", biomeKey)); - if (!objectResolved) - { - var locationObject = TryMigrateLegacyResource(document) ?? CreateLocationObjectValueForBiome(biomeKey, x, y); - updates.Add(Builders.Update.Set("locationObject", locationObject)); - updates.Add(Builders.Update.Set("locationObjectResolved", true)); - } - - if (updates.Count == 0) - return; - - var id = document.GetValue("_id").AsObjectId; - await _locations.UpdateOneAsync( - Builders.Filter.Eq("_id", id), - Builders.Update.Combine(updates)); - } - - private async Task DetermineBiomeKeyAsync(int x, int y) - { - if (x == 0 && y == 0) - return "plains"; - - var neighbors = await LoadNeighborBiomeKeysAsync(x, y); - var baseBiome = DetermineBaseBiomeKey(x, y); - if (neighbors.Count == 0) - return baseBiome; - - var dominantNeighbor = neighbors - .GroupBy(key => key) - .OrderByDescending(group => group.Count()) - .ThenBy(group => group.Key) - .First().Key; - - var bestBiome = baseBiome; - var bestScore = double.NegativeInfinity; - foreach (var candidate in BiomeOrder) - { - var score = candidate == baseBiome ? 2.5 : 0.35; - if (candidate == dominantNeighbor) - score += 1.8; - - foreach (var neighbor in neighbors) - { - if (!Biomes.TryGetValue(neighbor, out var neighborDefinition)) - continue; - - if (candidate == neighbor) - score += neighborDefinition.ContinuationWeight; - else if (neighborDefinition.TransitionWeights.TryGetValue(candidate, out var transitionWeight)) - score += transitionWeight; - } - - score += StableNoise(x, y, StableHash(candidate)) * 0.25; - if (score > bestScore) - { - bestScore = score; - bestBiome = candidate; - } - } - - return bestBiome; - } - - private async Task> LoadNeighborBiomeKeysAsync(int x, int y) - { - var coords = new[] { (x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1) }; - var filters = coords.Select(coord => - Builders.Filter.And( - Builders.Filter.Eq("coord.x", coord.Item1), - Builders.Filter.Eq("coord.y", coord.Item2))) - .ToList(); - var filter = Builders.Filter.Or(filters); - var neighbors = await _locations.Find(filter).ToListAsync(); - - return neighbors - .Where(doc => doc.Contains("biomeKey")) - .Select(doc => doc.GetValue("biomeKey", "plains").AsString) - .Where(key => !string.IsNullOrWhiteSpace(key)) - .ToList(); - } - - private static string DetermineBaseBiomeKey(int x, int y) - { - var temperature = StableNoise(x, y, 101); - var moisture = StableNoise(x, y, 202); - var ruggedness = StableNoise(x, y, 303); - - if (ruggedness > 0.74) - return "rocky"; - if (moisture > 0.72 && temperature < 0.75) - return "wetlands"; - if (moisture > 0.56) - return "forest"; - if (moisture < 0.22 && temperature > 0.58) - return "desert"; - return "plains"; - } - - private static BsonValue CreateLocationObjectValueForBiome(string biomeKey, int x, int y) - { - var roll = StableNoise(x, y, 401); - return biomeKey switch - { - "forest" => roll switch - { - < 0.35 => BsonNull.Value, - < 0.80 => CreateGatherableObjectDocument("wood", 60, 3), - < 0.95 => CreateGatherableObjectDocument("grass", 120, 10), - _ => CreateGatherableObjectDocument("stone", 40, 2) - }, - "rocky" => roll switch - { - < 0.60 => BsonNull.Value, - < 0.90 => CreateGatherableObjectDocument("stone", 40, 2), - _ => CreateGatherableObjectDocument("wood", 60, 3) - }, - "wetlands" => roll switch - { - < 0.40 => BsonNull.Value, - < 0.90 => CreateGatherableObjectDocument("grass", 120, 10), - _ => CreateGatherableObjectDocument("wood", 60, 3) - }, - "desert" => roll switch - { - < 0.70 => BsonNull.Value, - < 0.95 => CreateGatherableObjectDocument("stone", 40, 2), - _ => CreateGatherableObjectDocument("wood", 60, 3) - }, - _ => roll switch - { - < 0.50 => BsonNull.Value, - < 0.85 => CreateGatherableObjectDocument("grass", 120, 10), - _ => CreateGatherableObjectDocument("wood", 60, 3) - } - }; - } - - private static BsonDocument? TryMigrateLegacyResource(BsonDocument document) - { - if (!document.TryGetValue("resources", out var resourcesValue) || resourcesValue is not BsonArray resources) - return null; - - foreach (var resourceValue in resources) - { - if (resourceValue is not BsonDocument resource) - continue; - - var remainingQuantity = resource.GetValue("remainingQuantity", 0).ToInt32(); - if (remainingQuantity <= 0) - continue; - - var itemKey = NormalizeItemKey(resource.GetValue("itemKey", "").AsString); - var gatherQuantity = Math.Max(1, resource.GetValue("gatherQuantity", 1).ToInt32()); - return CreateGatherableObjectDocument(itemKey, remainingQuantity, gatherQuantity); - } - - return null; - } - - private static BsonDocument CreateGatherableObjectDocument(string itemKey, int remainingQuantity, int gatherQuantity) - { - var normalizedItemKey = NormalizeItemKey(itemKey); - return new BsonDocument - { - { "id", Guid.NewGuid().ToString("N") }, - { "objectType", "gatherable" }, - { "objectKey", $"{normalizedItemKey}_node" }, - { "name", HumanizeItemKey(normalizedItemKey) }, - { - "state", new BsonDocument - { - { "itemKey", normalizedItemKey }, - { "remainingQuantity", remainingQuantity }, - { "gatherQuantity", gatherQuantity } - } - } - }; - } - - private static VisibleLocation MapVisibleLocation(BsonDocument document) - { - var coord = document.GetValue("coord", new BsonDocument()).AsBsonDocument; - var locationObject = MapVisibleLocationObject(document.GetValue("locationObject", BsonNull.Value)); - var idValue = document.GetValue("_id", BsonNull.Value); - string? id = null; - if (!idValue.IsBsonNull) - { - id = idValue.BsonType == BsonType.ObjectId ? idValue.AsObjectId.ToString() : idValue.ToString(); - } - - return new VisibleLocation - { - Id = id, - Name = document.GetValue("name", "").AsString, - Coord = new LocationCoord - { - X = coord.GetValue("x", 0).ToInt32(), - Y = coord.GetValue("y", 0).ToInt32() - }, - BiomeKey = document.GetValue("biomeKey", "plains").AsString, - LocationObject = locationObject - }; - } - - private static VisibleLocationObject? MapVisibleLocationObject(BsonValue value) - { - if (value.IsBsonNull || value is not BsonDocument document) - return null; - - var stateDoc = document.GetValue("state", new BsonDocument()).AsBsonDocument; - return new VisibleLocationObject - { - Id = document.GetValue("id", "").AsString, - ObjectType = document.GetValue("objectType", "").AsString, - ObjectKey = document.GetValue("objectKey", "").AsString, - Name = document.GetValue("name", "").AsString, - State = new VisibleLocationObjectState - { - ItemKey = stateDoc.GetValue("itemKey", "").AsString, - RemainingQuantity = stateDoc.GetValue("remainingQuantity", 0).ToInt32(), - GatherQuantity = stateDoc.GetValue("gatherQuantity", 1).ToInt32() - } - }; - } - - private void EnsureLocationCoordIndexes() - { - var indexes = _locations.Indexes.List().ToList(); - foreach (var index in indexes) - { - var name = index.GetValue("name", "").AsString; - if (name == "_id_") - continue; - - var keyDoc = index.GetValue("key", new BsonDocument()).AsBsonDocument; - if (IsLegacyCoordIndex(keyDoc) || IsUnexpectedCoordIndex(keyDoc)) - _locations.Indexes.DropOne(name); - } - - var coordIndex = new BsonDocument { { "coord.x", 1 }, { "coord.y", 1 } }; - var coordIndexOptions = new CreateIndexOptions { Unique = true, Name = CoordIndexName }; - _locations.Indexes.CreateOne(new CreateIndexModel(coordIndex, coordIndexOptions)); - } - - private static bool IsLegacyCoordIndex(BsonDocument keyDoc) => - keyDoc.ElementCount == 2 && - keyDoc.TryGetValue("Coord.X", out var xValue) && - xValue.IsInt32 && xValue.AsInt32 == 1 && - keyDoc.TryGetValue("Coord.Y", out var yValue) && - yValue.IsInt32 && yValue.AsInt32 == 1; - - private static bool IsUnexpectedCoordIndex(BsonDocument keyDoc) - { - var hasLower = keyDoc.Contains("coord.x") || keyDoc.Contains("coord.y"); - var hasUpper = keyDoc.Contains("Coord.X") || keyDoc.Contains("Coord.Y"); - return hasUpper || (hasLower && !(keyDoc.Contains("coord.x") && keyDoc.Contains("coord.y"))); - } - public async Task DeleteForOwnerAsync(string id, string ownerUserId, bool allowAnyOwner) { var filter = Builders.Filter.Eq(c => c.Id, id); @@ -456,37 +44,4 @@ public class CharacterStore var result = await _col.DeleteOneAsync(filter); return result.DeletedCount > 0; } - - private static string DefaultLocationName(int x, int y) - { - if (x == 0 && y == 0) - return "Origin"; - return $"Location {x},{y}"; - } - - private static string NormalizeItemKey(string itemKey) => itemKey.Trim().ToLowerInvariant(); - - private static string HumanizeItemKey(string itemKey) - { - return string.Join(" ", itemKey.Split('_', StringSplitOptions.RemoveEmptyEntries) - .Where(part => part.Length > 0) - .Select(part => char.ToUpperInvariant(part[0]) + part[1..])); - } - - private static double StableNoise(int x, int y, int salt) - { - var value = Math.Sin((x * 12.9898) + (y * 78.233) + ((WorldSeed + salt) * 0.1597)) * 43758.5453; - return value - Math.Floor(value); - } - - private static int StableHash(string value) - { - unchecked - { - var hash = 17; - foreach (var ch in value) - hash = (hash * 31) + ch; - return hash; - } - } } diff --git a/microservices/CharacterApi/appsettings.Development.json b/microservices/CharacterApi/appsettings.Development.json index d50df68..8b4646a 100644 --- a/microservices/CharacterApi/appsettings.Development.json +++ b/microservices/CharacterApi/appsettings.Development.json @@ -1,6 +1,8 @@ { - "Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5001" } } }, - "MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" }, - "Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" }, - "Logging": { "LogLevel": { "Default": "Information" } } -} + "Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5001" } } }, + "MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" }, + "Services": { "LocationsApiBaseUrl": "http://localhost:5002" }, + "InternalApi": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!" }, + "Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" }, + "Logging": { "LogLevel": { "Default": "Information" } } +} diff --git a/microservices/CharacterApi/appsettings.json b/microservices/CharacterApi/appsettings.json index 2a44cad..23b5468 100644 --- a/microservices/CharacterApi/appsettings.json +++ b/microservices/CharacterApi/appsettings.json @@ -1,7 +1,9 @@ { - "Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5001" } } }, - "MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" }, - "Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" }, - "Logging": { "LogLevel": { "Default": "Information" } }, - "AllowedHosts": "*" -} + "Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5001" } } }, + "MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" }, + "Services": { "LocationsApiBaseUrl": "https://ploc.ranaze.com" }, + "InternalApi": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!" }, + "Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" }, + "Logging": { "LogLevel": { "Default": "Information" } }, + "AllowedHosts": "*" +} diff --git a/microservices/LocationsApi/Controllers/LocationsController.cs b/microservices/LocationsApi/Controllers/LocationsController.cs index 4986d7b..b758fab 100644 --- a/microservices/LocationsApi/Controllers/LocationsController.cs +++ b/microservices/LocationsApi/Controllers/LocationsController.cs @@ -60,6 +60,68 @@ public class LocationsController : ControllerBase 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); + 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() @@ -254,4 +316,55 @@ public class LocationsController : ControllerBase }); } } + + 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(); } diff --git a/microservices/LocationsApi/Models/BiomeDefinition.cs b/microservices/LocationsApi/Models/BiomeDefinition.cs new file mode 100644 index 0000000..a153b3c --- /dev/null +++ b/microservices/LocationsApi/Models/BiomeDefinition.cs @@ -0,0 +1,23 @@ +using MongoDB.Bson.Serialization.Attributes; + +namespace LocationsApi.Models; + +[BsonIgnoreExtraElements] +public class BiomeDefinition +{ + [BsonId] + [BsonElement("biomeKey")] + public string BiomeKey { get; set; } = string.Empty; + + [BsonElement("continuationWeight")] + public double ContinuationWeight { get; set; } + + [BsonElement("transitionWeights")] + public List TransitionWeights { get; set; } = []; + + [BsonElement("objectSpawnRules")] + public List ObjectSpawnRules { get; set; } = []; + + [BsonElement("updatedUtc")] + public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; +} diff --git a/microservices/LocationsApi/Models/BiomeObjectSpawnRule.cs b/microservices/LocationsApi/Models/BiomeObjectSpawnRule.cs new file mode 100644 index 0000000..e34829b --- /dev/null +++ b/microservices/LocationsApi/Models/BiomeObjectSpawnRule.cs @@ -0,0 +1,30 @@ +using MongoDB.Bson.Serialization.Attributes; + +namespace LocationsApi.Models; + +public class BiomeObjectSpawnRule +{ + [BsonElement("resultType")] + public string ResultType { get; set; } = "none"; + + [BsonElement("itemKey")] + [BsonIgnoreIfNull] + public string? ItemKey { get; set; } + + [BsonElement("objectKey")] + [BsonIgnoreIfNull] + public string? ObjectKey { get; set; } + + [BsonElement("displayName")] + [BsonIgnoreIfNull] + public string? DisplayName { get; set; } + + [BsonElement("remainingQuantity")] + public int RemainingQuantity { get; set; } + + [BsonElement("gatherQuantity")] + public int GatherQuantity { get; set; } = 1; + + [BsonElement("weight")] + public double Weight { get; set; } +} diff --git a/microservices/LocationsApi/Models/BiomeTransitionWeight.cs b/microservices/LocationsApi/Models/BiomeTransitionWeight.cs new file mode 100644 index 0000000..d6fa837 --- /dev/null +++ b/microservices/LocationsApi/Models/BiomeTransitionWeight.cs @@ -0,0 +1,12 @@ +using MongoDB.Bson.Serialization.Attributes; + +namespace LocationsApi.Models; + +public class BiomeTransitionWeight +{ + [BsonElement("targetBiomeKey")] + public string TargetBiomeKey { get; set; } = string.Empty; + + [BsonElement("weight")] + public double Weight { get; set; } +} diff --git a/microservices/LocationsApi/Models/InternalVisibleLocationsRequest.cs b/microservices/LocationsApi/Models/InternalVisibleLocationsRequest.cs new file mode 100644 index 0000000..801c9fc --- /dev/null +++ b/microservices/LocationsApi/Models/InternalVisibleLocationsRequest.cs @@ -0,0 +1,10 @@ +namespace LocationsApi.Models; + +public class InternalVisibleLocationsRequest +{ + public int X { get; set; } + + public int Y { get; set; } + + public int Radius { get; set; } +} diff --git a/microservices/LocationsApi/Models/UpsertBiomeDefinitionRequest.cs b/microservices/LocationsApi/Models/UpsertBiomeDefinitionRequest.cs new file mode 100644 index 0000000..4655c45 --- /dev/null +++ b/microservices/LocationsApi/Models/UpsertBiomeDefinitionRequest.cs @@ -0,0 +1,10 @@ +namespace LocationsApi.Models; + +public class UpsertBiomeDefinitionRequest +{ + public double ContinuationWeight { get; set; } + + public List TransitionWeights { get; set; } = []; + + public List ObjectSpawnRules { get; set; } = []; +} diff --git a/microservices/LocationsApi/Models/VisibleLocationObjectResponse.cs b/microservices/LocationsApi/Models/VisibleLocationObjectResponse.cs new file mode 100644 index 0000000..797c142 --- /dev/null +++ b/microservices/LocationsApi/Models/VisibleLocationObjectResponse.cs @@ -0,0 +1,14 @@ +namespace LocationsApi.Models; + +public class VisibleLocationObjectResponse +{ + public string Id { get; set; } = string.Empty; + + public string ObjectType { get; set; } = string.Empty; + + public string ObjectKey { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public VisibleLocationObjectStateResponse State { get; set; } = new(); +} diff --git a/microservices/LocationsApi/Models/VisibleLocationObjectStateResponse.cs b/microservices/LocationsApi/Models/VisibleLocationObjectStateResponse.cs new file mode 100644 index 0000000..e11dc37 --- /dev/null +++ b/microservices/LocationsApi/Models/VisibleLocationObjectStateResponse.cs @@ -0,0 +1,10 @@ +namespace LocationsApi.Models; + +public class VisibleLocationObjectStateResponse +{ + public string ItemKey { get; set; } = string.Empty; + + public int RemainingQuantity { get; set; } + + public int GatherQuantity { get; set; } = 1; +} diff --git a/microservices/LocationsApi/Models/VisibleLocationResponse.cs b/microservices/LocationsApi/Models/VisibleLocationResponse.cs new file mode 100644 index 0000000..06720a5 --- /dev/null +++ b/microservices/LocationsApi/Models/VisibleLocationResponse.cs @@ -0,0 +1,14 @@ +namespace LocationsApi.Models; + +public class VisibleLocationResponse +{ + public string? Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public Coord Coord { get; set; } = new(); + + public string BiomeKey { get; set; } = "plains"; + + public VisibleLocationObjectResponse? LocationObject { get; set; } +} diff --git a/microservices/LocationsApi/Models/VisibleLocationWindowResponse.cs b/microservices/LocationsApi/Models/VisibleLocationWindowResponse.cs new file mode 100644 index 0000000..1f5cc30 --- /dev/null +++ b/microservices/LocationsApi/Models/VisibleLocationWindowResponse.cs @@ -0,0 +1,8 @@ +namespace LocationsApi.Models; + +public class VisibleLocationWindowResponse +{ + public int GeneratedCount { get; set; } + + public List Locations { get; set; } = []; +} diff --git a/microservices/LocationsApi/Services/LocationStore.cs b/microservices/LocationsApi/Services/LocationStore.cs index 261e33b..04f5449 100644 --- a/microservices/LocationsApi/Services/LocationStore.cs +++ b/microservices/LocationsApi/Services/LocationStore.cs @@ -9,6 +9,7 @@ public class LocationStore private readonly IMongoCollection _col; private readonly IMongoCollection _rawCol; private readonly IMongoCollection _characters; + private readonly IMongoCollection _biomeDefinitions; private const string CoordIndexName = "coord_x_1_coord_y_1"; public LocationStore(IConfiguration cfg) @@ -22,6 +23,7 @@ public class LocationStore _col = db.GetCollection(collectionName); _rawCol = db.GetCollection(collectionName); _characters = db.GetCollection("Characters"); + _biomeDefinitions = db.GetCollection("BiomeDefinitions"); EnsureCoordIndexes(); @@ -232,6 +234,61 @@ public class LocationStore await _col.UpdateOneAsync(filter, update); } + public async Task GetOrCreateVisibleLocationsAsync(int x, int y, int radius) + { + var generatedCount = await EnsureVisibleLocationsExistAsync(x, y, radius); + var locations = await GetVisibleLocationsAsync(x, y, radius, ensureMetadata: true); + return new VisibleLocationWindowResponse + { + GeneratedCount = generatedCount, + Locations = locations + }; + } + + public Task> GetBiomeDefinitionsAsync() => + _biomeDefinitions.Find(Builders.Filter.Empty) + .SortBy(definition => definition.BiomeKey) + .ToListAsync(); + + public async Task GetBiomeDefinitionAsync(string biomeKey) + { + var normalizedBiomeKey = NormalizeBiomeKey(biomeKey); + return await _biomeDefinitions.Find(definition => definition.BiomeKey == normalizedBiomeKey) + .FirstOrDefaultAsync(); + } + + public async Task CreateBiomeDefinitionAsync(BiomeDefinition definition) + { + definition.BiomeKey = NormalizeBiomeKey(definition.BiomeKey); + definition.TransitionWeights = NormalizeTransitionWeights(definition.TransitionWeights); + definition.ObjectSpawnRules = NormalizeObjectSpawnRules(definition.ObjectSpawnRules); + definition.UpdatedUtc = DateTime.UtcNow; + + var existing = await GetBiomeDefinitionAsync(definition.BiomeKey); + if (existing is not null) + return false; + + await _biomeDefinitions.InsertOneAsync(definition); + return true; + } + + public async Task UpsertBiomeDefinitionAsync(string biomeKey, UpsertBiomeDefinitionRequest request) + { + var normalizedBiomeKey = NormalizeBiomeKey(biomeKey); + var definition = new BiomeDefinition + { + BiomeKey = normalizedBiomeKey, + ContinuationWeight = request.ContinuationWeight, + TransitionWeights = NormalizeTransitionWeights(request.TransitionWeights), + ObjectSpawnRules = NormalizeObjectSpawnRules(request.ObjectSpawnRules), + UpdatedUtc = DateTime.UtcNow + }; + + var filter = Builders.Filter.Eq(existing => existing.BiomeKey, normalizedBiomeKey); + await _biomeDefinitions.ReplaceOneAsync(filter, definition, new ReplaceOptions { IsUpsert = true }); + return definition; + } + private void EnsureCoordIndexes() { var indexes = _rawCol.Indexes.List().ToList(); @@ -271,6 +328,13 @@ public class LocationStore private void EnsureOriginLocation() { + var biomeDefinitions = LoadBiomeDefinitions(); + if (biomeDefinitions.Count == 0) + return; + + var originBiomeKey = biomeDefinitions.Any(definition => definition.BiomeKey == "plains") + ? "plains" + : biomeDefinitions[0].BiomeKey; var filter = Builders.Filter.And( Builders.Filter.Eq(l => l.Coord.X, 0), Builders.Filter.Eq(l => l.Coord.Y, 0) @@ -283,8 +347,8 @@ public class LocationStore { Name = "Origin", Coord = new Coord { X = 0, Y = 0 }, - BiomeKey = DetermineBiomeKey(0, 0), - LocationObject = CreateLocationObjectForBiome(DetermineBiomeKey(0, 0), 0, 0), + BiomeKey = originBiomeKey, + LocationObject = CreateLocationObjectForBiome(biomeDefinitions, originBiomeKey, 0, 0), LocationObjectResolved = true, CreatedUtc = DateTime.UtcNow }; @@ -301,16 +365,97 @@ public class LocationStore private static string NormalizeItemKey(string itemKey) => itemKey.Trim().ToLowerInvariant(); + private async Task> GetVisibleLocationsAsync(int x, int y, int radius, bool ensureMetadata) + { + var minX = x - radius; + var maxX = x + radius; + var minY = y - radius; + var maxY = y + radius; + + var filter = Builders.Filter.And( + Builders.Filter.Gte(location => location.Coord.X, minX), + Builders.Filter.Lte(location => location.Coord.X, maxX), + Builders.Filter.Gte(location => location.Coord.Y, minY), + Builders.Filter.Lte(location => location.Coord.Y, maxY) + ); + + var locations = await _col.Find(filter).ToListAsync(); + if (ensureMetadata) + { + for (var index = 0; index < locations.Count; index++) + locations[index] = await EnsureLocationMetadataAsync(locations[index]); + } + + return locations.Select(MapVisibleLocation).ToList(); + } + + private async Task EnsureVisibleLocationsExistAsync(int x, int y, int radius) + { + var biomeDefinitions = await LoadBiomeDefinitionsAsync(); + var generatedCount = 0; + + for (var currentX = x - radius; currentX <= x + radius; currentX++) + { + for (var currentY = y - radius; currentY <= y + radius; currentY++) + { + if (await EnsureLocationStateAsync(currentX, currentY, biomeDefinitions)) + generatedCount += 1; + } + } + + return generatedCount; + } + + private async Task EnsureLocationStateAsync(int x, int y, IReadOnlyList biomeDefinitions) + { + var filter = Builders.Filter.And( + Builders.Filter.Eq("coord.x", x), + Builders.Filter.Eq("coord.y", y) + ); + + var existing = await _rawCol.Find(filter).FirstOrDefaultAsync(); + if (existing is not null) + { + var typedLocation = await _col.Find(location => location.Id == existing["_id"].AsObjectId.ToString()).FirstOrDefaultAsync(); + if (typedLocation is not null) + await EnsureLocationMetadataAsync(typedLocation); + return false; + } + + var biomeKey = await DetermineBiomeKeyAsync(x, y, biomeDefinitions); + var locationObject = CreateLocationObjectForBiome(biomeDefinitions, biomeKey, x, y); + BsonValue locationObjectValue = locationObject is null ? BsonNull.Value : locationObject.ToBsonDocument(); + var update = Builders.Update + .SetOnInsert("_id", ObjectId.GenerateNewId()) + .SetOnInsert("name", DefaultLocationName(x, y)) + .SetOnInsert("coord", new BsonDocument { { "x", x }, { "y", y } }) + .SetOnInsert("biomeKey", biomeKey) + .SetOnInsert("locationObject", locationObjectValue) + .SetOnInsert("locationObjectResolved", true) + .SetOnInsert("createdUtc", DateTime.UtcNow); + + try + { + var result = await _rawCol.UpdateOneAsync(filter, update, new UpdateOptions { IsUpsert = true }); + return result.UpsertedId is not null; + } + catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) + { + return false; + } + } + private async Task EnsureLocationMetadataAsync(Location location) { if (!string.IsNullOrWhiteSpace(location.BiomeKey) && location.LocationObjectResolved) return location; + var biomeDefinitions = await LoadBiomeDefinitionsAsync(); var biomeKey = location.BiomeKey; if (string.IsNullOrWhiteSpace(biomeKey)) - biomeKey = DetermineBiomeKey(location.Coord.X, location.Coord.Y); + biomeKey = await DetermineBiomeKeyAsync(location.Coord.X, location.Coord.Y, biomeDefinitions); - var migratedObject = TryMigrateLegacyResources(location) ?? CreateLocationObjectForBiome(biomeKey, location.Coord.X, location.Coord.Y); + var migratedObject = TryMigrateLegacyResources(location) ?? CreateLocationObjectForBiome(biomeDefinitions, biomeKey, location.Coord.X, location.Coord.Y); var filter = Builders.Filter.And( Builders.Filter.Eq(l => l.Id, location.Id) ); @@ -325,6 +470,38 @@ public class LocationStore return location; } + private static VisibleLocationResponse MapVisibleLocation(Location location) + { + return new VisibleLocationResponse + { + Id = location.Id, + Name = location.Name, + Coord = new Coord { X = location.Coord.X, Y = location.Coord.Y }, + BiomeKey = location.BiomeKey, + LocationObject = MapVisibleLocationObject(location.LocationObject) + }; + } + + private static VisibleLocationObjectResponse? MapVisibleLocationObject(LocationObject? locationObject) + { + if (locationObject is null) + return null; + + return new VisibleLocationObjectResponse + { + Id = locationObject.ObjectId, + ObjectType = locationObject.ObjectType, + ObjectKey = locationObject.ObjectKey, + Name = locationObject.Name, + State = new VisibleLocationObjectStateResponse + { + ItemKey = locationObject.State.ItemKey, + RemainingQuantity = locationObject.State.RemainingQuantity, + GatherQuantity = locationObject.State.GatherQuantity + } + }; + } + private static LocationObject? TryMigrateLegacyResources(Location location) { var legacyResource = location.Resources.FirstOrDefault(r => r.RemainingQuantity > 0); @@ -337,73 +514,105 @@ public class LocationStore legacyResource.GatherQuantity); } - private static string DetermineBiomeKey(int x, int y) + private async Task DetermineBiomeKeyAsync(int x, int y, IReadOnlyList biomeDefinitions) { if (x == 0 && y == 0) - return "plains"; + return biomeDefinitions.Any(definition => definition.BiomeKey == "plains") + ? "plains" + : biomeDefinitions[0].BiomeKey; - var regionX = FloorDiv(x, 4); - var regionY = FloorDiv(y, 4); - var roll = Math.Abs(HashCode.Combine(regionX, regionY, 7919)) % 100; - if (roll < 35) - return "plains"; - if (roll < 60) - return "forest"; - if (roll < 80) - return "rocky"; - if (roll < 92) - return "wetlands"; - return "desert"; - } + var neighbors = await LoadNeighborBiomeKeysAsync(x, y); + var baseBiome = DetermineBaseBiomeKey(x, y); + if (neighbors.Count == 0) + return biomeDefinitions.Any(definition => definition.BiomeKey == baseBiome) + ? baseBiome + : biomeDefinitions[0].BiomeKey; - private static LocationObject? CreateLocationObjectForBiome(string biomeKey, int x, int y) - { - var roll = Math.Abs(HashCode.Combine(x, y, 1543)) % 100; - return biomeKey switch + var dominantNeighbor = neighbors + .GroupBy(key => key) + .OrderByDescending(group => group.Count()) + .ThenBy(group => group.Key) + .First().Key; + + var bestBiome = baseBiome; + var bestScore = double.NegativeInfinity; + foreach (var candidate in biomeDefinitions) { - "forest" => roll switch + var score = candidate.BiomeKey == baseBiome ? 2.5 : 0.35; + if (candidate.BiomeKey == dominantNeighbor) + score += 1.8; + + foreach (var neighbor in neighbors) { - < 35 => null, - < 80 => CreateGatherableObject("wood", 60, 3), - < 95 => CreateGatherableObject("grass", 120, 10), - _ => CreateGatherableObject("stone", 40, 2) - }, - "rocky" => roll switch - { - < 60 => null, - < 90 => CreateGatherableObject("stone", 40, 2), - _ => CreateGatherableObject("wood", 60, 3) - }, - "wetlands" => roll switch - { - < 40 => null, - < 90 => CreateGatherableObject("grass", 120, 10), - _ => CreateGatherableObject("wood", 60, 3) - }, - "desert" => roll switch - { - < 70 => null, - < 95 => CreateGatherableObject("stone", 40, 2), - _ => CreateGatherableObject("wood", 60, 3) - }, - _ => roll switch - { - < 50 => null, - < 85 => CreateGatherableObject("grass", 120, 10), - _ => CreateGatherableObject("wood", 60, 3) + var neighborDefinition = biomeDefinitions.FirstOrDefault(definition => definition.BiomeKey == neighbor); + if (neighborDefinition is null) + continue; + + if (candidate.BiomeKey == neighbor) + { + score += neighborDefinition.ContinuationWeight; + continue; + } + + var transition = neighborDefinition.TransitionWeights + .FirstOrDefault(weight => weight.TargetBiomeKey == candidate.BiomeKey); + if (transition is not null) + score += transition.Weight; } - }; + + score += StableNoise(x, y, StableHash(candidate.BiomeKey)) * 0.25; + if (score > bestScore) + { + bestScore = score; + bestBiome = candidate.BiomeKey; + } + } + + return bestBiome; } - private static LocationObject CreateGatherableObject(string itemKey, int remainingQuantity, int gatherQuantity) + private static LocationObject? CreateLocationObjectForBiome(IReadOnlyList biomeDefinitions, string biomeKey, int x, int y) + { + var biome = biomeDefinitions.FirstOrDefault(definition => definition.BiomeKey == biomeKey) + ?? throw new InvalidOperationException($"Missing biome definition for '{biomeKey}'."); + + var totalWeight = biome.ObjectSpawnRules.Sum(rule => rule.Weight); + if (totalWeight <= 0) + return null; + + var roll = StableNoise(x, y, 401) * totalWeight; + var cumulative = 0.0; + foreach (var rule in biome.ObjectSpawnRules) + { + cumulative += rule.Weight; + if (roll > cumulative) + continue; + + if (string.Equals(rule.ResultType, "none", StringComparison.OrdinalIgnoreCase)) + return null; + if (!string.Equals(rule.ResultType, "gatherable", StringComparison.OrdinalIgnoreCase) || string.IsNullOrWhiteSpace(rule.ItemKey)) + return null; + + return CreateGatherableObject( + rule.ItemKey, + Math.Max(0, rule.RemainingQuantity), + Math.Max(1, rule.GatherQuantity), + rule.ObjectKey, + rule.DisplayName); + } + + return null; + } + + private static LocationObject CreateGatherableObject(string itemKey, int remainingQuantity, int gatherQuantity, string? objectKey = null, string? displayName = null) { var normalizedItemKey = NormalizeItemKey(itemKey); return new LocationObject { ObjectId = Guid.NewGuid().ToString("N"), ObjectType = "gatherable", - ObjectKey = $"{normalizedItemKey}_node", - Name = HumanizeItemKey(normalizedItemKey), + ObjectKey = string.IsNullOrWhiteSpace(objectKey) ? $"{normalizedItemKey}_node" : objectKey, + Name = string.IsNullOrWhiteSpace(displayName) ? HumanizeItemKey(normalizedItemKey) : displayName, State = new LocationObjectState { ItemKey = normalizedItemKey, @@ -428,6 +637,110 @@ public class LocationStore return quotient; } + private async Task> LoadNeighborBiomeKeysAsync(int x, int y) + { + var coords = new[] { (x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1) }; + var filters = coords.Select(coord => + Builders.Filter.And( + Builders.Filter.Eq("coord.x", coord.Item1), + Builders.Filter.Eq("coord.y", coord.Item2))) + .ToList(); + var filter = Builders.Filter.Or(filters); + var neighbors = await _rawCol.Find(filter).ToListAsync(); + + return neighbors + .Where(doc => doc.Contains("biomeKey")) + .Select(doc => doc.GetValue("biomeKey", "").AsString) + .Where(key => !string.IsNullOrWhiteSpace(key)) + .ToList(); + } + + private List LoadBiomeDefinitions() + { + return _biomeDefinitions.Find(Builders.Filter.Empty) + .SortBy(definition => definition.BiomeKey) + .ToList(); + } + + private async Task> LoadBiomeDefinitionsAsync() + { + var definitions = await _biomeDefinitions.Find(Builders.Filter.Empty) + .SortBy(definition => definition.BiomeKey) + .ToListAsync(); + if (definitions.Count == 0) + throw new InvalidOperationException("No biome definitions exist in the BiomeDefinitions collection."); + return definitions; + } + + private static string DetermineBaseBiomeKey(int x, int y) + { + var temperature = StableNoise(x, y, 101); + var moisture = StableNoise(x, y, 202); + var ruggedness = StableNoise(x, y, 303); + + if (ruggedness > 0.74) + return "rocky"; + if (moisture > 0.72 && temperature < 0.75) + return "wetlands"; + if (moisture > 0.56) + return "forest"; + if (moisture < 0.22 && temperature > 0.58) + return "desert"; + return "plains"; + } + + private static double StableNoise(int x, int y, int salt) + { + var value = Math.Sin((x * 12.9898) + (y * 78.233) + ((1729 + salt) * 0.1597)) * 43758.5453; + return value - Math.Floor(value); + } + + private static int StableHash(string value) + { + unchecked + { + var hash = 17; + foreach (var ch in value) + hash = (hash * 31) + ch; + return hash; + } + } + + private static string NormalizeBiomeKey(string biomeKey) => biomeKey.Trim().ToLowerInvariant(); + + private static List NormalizeTransitionWeights(IEnumerable transitionWeights) + { + return transitionWeights + .Where(weight => !string.IsNullOrWhiteSpace(weight.TargetBiomeKey)) + .Select(weight => new BiomeTransitionWeight + { + TargetBiomeKey = NormalizeBiomeKey(weight.TargetBiomeKey), + Weight = weight.Weight + }) + .ToList(); + } + + private static List NormalizeObjectSpawnRules(IEnumerable objectSpawnRules) + { + return objectSpawnRules.Select(rule => new BiomeObjectSpawnRule + { + ResultType = string.IsNullOrWhiteSpace(rule.ResultType) ? "none" : rule.ResultType.Trim().ToLowerInvariant(), + ItemKey = string.IsNullOrWhiteSpace(rule.ItemKey) ? null : NormalizeItemKey(rule.ItemKey), + 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(); + } + + private static string DefaultLocationName(int x, int y) + { + if (x == 0 && y == 0) + return "Origin"; + return $"Location {x},{y}"; + } + private static LocationObject CloneLocationObject(LocationObject source) { return new LocationObject diff --git a/microservices/LocationsApi/appsettings.Development.json b/microservices/LocationsApi/appsettings.Development.json index c478bd3..e9c2463 100644 --- a/microservices/LocationsApi/appsettings.Development.json +++ b/microservices/LocationsApi/appsettings.Development.json @@ -2,6 +2,9 @@ "Services": { "InventoryApiBaseUrl": "http://localhost:5003" }, + "InternalApi": { + "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!" + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/microservices/LocationsApi/appsettings.json b/microservices/LocationsApi/appsettings.json index 45d1ba6..118d09a 100644 --- a/microservices/LocationsApi/appsettings.json +++ b/microservices/LocationsApi/appsettings.json @@ -2,6 +2,7 @@ "Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5002" } } }, "MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" }, "Services": { "InventoryApiBaseUrl": "https://pinv.ranaze.com" }, + "InternalApi": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!" }, "Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" }, "Logging": { "LogLevel": { "Default": "Information" } }, "AllowedHosts": "*"