using CharacterApi.Models; using MongoDB.Bson; using MongoDB.Driver; 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 BiomeDefinition( "plains", 3.0, new Dictionary { ["forest"] = 1.7, ["wetlands"] = 0.9, ["rocky"] = 0.8, ["desert"] = 0.4 }, new[] { new ResourceRule("grass", 180, 420, 12), new ResourceRule("wood", 25, 90, 3), new ResourceRule("stone", 10, 40, 2) }), ["forest"] = new BiomeDefinition( "forest", 3.4, new Dictionary { ["plains"] = 1.6, ["wetlands"] = 1.3, ["rocky"] = 0.5, ["desert"] = 0.1 }, new[] { new ResourceRule("wood", 220, 520, 6), new ResourceRule("grass", 90, 220, 8), new ResourceRule("stone", 15, 45, 2) }), ["wetlands"] = new BiomeDefinition( "wetlands", 3.1, new Dictionary { ["forest"] = 1.5, ["plains"] = 1.1, ["rocky"] = 0.2, ["desert"] = 0.05 }, new[] { new ResourceRule("grass", 260, 600, 15), new ResourceRule("wood", 40, 120, 4) }), ["rocky"] = new BiomeDefinition( "rocky", 3.0, new Dictionary { ["plains"] = 1.2, ["forest"] = 0.6, ["desert"] = 1.1, ["wetlands"] = 0.1 }, new[] { new ResourceRule("stone", 220, 540, 8), new ResourceRule("wood", 10, 40, 2), new ResourceRule("grass", 20, 70, 4) }), ["desert"] = new BiomeDefinition( "desert", 3.2, new Dictionary { ["rocky"] = 1.4, ["plains"] = 0.8, ["forest"] = 0.1, ["wetlands"] = 0.05 }, new[] { new ResourceRule("stone", 80, 220, 5), new ResourceRule("grass", 5, 25, 2) }) }; public sealed record VisibleLocationResult(List Locations, int GeneratedCount); public CharacterStore(IConfiguration cfg) { var cs = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017"; var dbName = cfg["MongoDB:DatabaseName"] ?? "GameDb"; 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)); } public Task CreateAsync(Character character) => _col.InsertOneAsync(character); public Task> GetForOwnerAsync(string ownerUserId) => _col.Find(c => c.OwnerUserId == ownerUserId).ToListAsync(); public async Task GetByIdAsync(string id) => await _col.Find(c => c.Id == id).FirstOrDefaultAsync(); public async Task UpdateCoordAsync(string id, Coord coord) { var filter = Builders.Filter.Eq(c => c.Id, id); var update = Builders.Update.Set(c => c.Coord, coord); var result = await _col.UpdateOneAsync(filter, update); return result.ModifiedCount > 0 || result.MatchedCount > 0; } public Task> GetVisibleLocationsAsync(Character character) { return 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(); 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 static string DefaultLocationName(int x, int y) { if (x == 0 && y == 0) return "Origin"; return $"Location {x},{y}"; } private static VisibleLocation MapVisibleLocation(BsonDocument document) { var coord = document.GetValue("coord", new BsonDocument()).AsBsonDocument; 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, Resources = MapVisibleLocationResources(document) }; } 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("resources", BuildResourcesDocument(biomeKey, x, y)) .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; if (!document.Contains("biomeKey")) updates.Add(Builders.Update.Set("biomeKey", biomeKey)); if (!document.Contains("resources")) updates.Add(Builders.Update.Set("resources", BuildResourcesDocument(biomeKey, x, y))); 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 BsonArray BuildResourcesDocument(string biomeKey, int x, int y) { if (!Biomes.TryGetValue(biomeKey, out var biome)) biome = Biomes["plains"]; var resources = new BsonArray(); foreach (var rule in biome.ResourceRules) { var roll = StableNoise(x, y, StableHash(rule.ItemKey)); var quantity = rule.MinQuantity + (int)Math.Round(roll * (rule.MaxQuantity - rule.MinQuantity)); if (quantity <= 0) continue; resources.Add(new BsonDocument { { "itemKey", rule.ItemKey }, { "remainingQuantity", quantity }, { "gatherQuantity", rule.GatherQuantity } }); } return resources; } 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 = 23; foreach (var c in value) hash = (hash * 31) + c; return hash; } } private static List MapVisibleLocationResources(BsonDocument document) { if (!document.TryGetValue("resources", out var resourcesValue) || resourcesValue.BsonType != BsonType.Array) return []; var results = new List(); foreach (var value in resourcesValue.AsBsonArray) { if (value.BsonType != BsonType.Document) continue; var resource = value.AsBsonDocument; results.Add(new VisibleLocationResource { ItemKey = resource.GetValue("itemKey", "").AsString, RemainingQuantity = resource.GetValue("remainingQuantity", 0).ToInt32(), GatherQuantity = resource.GetValue("gatherQuantity", 1).ToInt32() }); } return results; } 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); if (!allowAnyOwner) { filter = Builders.Filter.And( filter, Builders.Filter.Eq(c => c.OwnerUserId, ownerUserId) ); } var result = await _col.DeleteOneAsync(filter); return result.DeletedCount > 0; } private sealed record ResourceRule(string ItemKey, int MinQuantity, int MaxQuantity, int GatherQuantity); private sealed record BiomeDefinition( string Key, double ContinuationWeight, Dictionary TransitionWeights, IReadOnlyList ResourceRules); }