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("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) { 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) => 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); 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 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; } } }