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"; 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(); if (ensureGenerated) { foreach (var document in documents) await EnsureLocationObjectAsync(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++) { var filter = Builders.Filter.And( Builders.Filter.Eq("coord.x", x), Builders.Filter.Eq("coord.y", y) ); var update = Builders.Update .SetOnInsert("_id", ObjectId.GenerateNewId()) .SetOnInsert("name", DefaultLocationName(x, y)) .SetOnInsert("coord", new BsonDocument { { "x", x }, { "y", y } }) .SetOnInsert("biomeKey", DetermineBiomeKey(x, y)) .SetOnInsert("locationObject", CreateLocationObjectValueForBiome(DetermineBiomeKey(x, y), x, y)) .SetOnInsert("locationObjectResolved", true) .SetOnInsert("createdUtc", DateTime.UtcNow); try { var result = await _locations.UpdateOneAsync(filter, update, new UpdateOptions { IsUpsert = true }); if (result.UpsertedId is not null) generatedCount += 1; } catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) { // Another request or service instance created it first. } } } 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 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", "").AsString, LocationObject = locationObject }; } private async Task EnsureLocationObjectAsync(BsonDocument document) { var hasBiome = document.TryGetValue("biomeKey", out var biomeValue) && biomeValue.BsonType == BsonType.String && !string.IsNullOrWhiteSpace(biomeValue.AsString); var resolved = document.TryGetValue("locationObjectResolved", out var resolvedValue) && resolvedValue.ToBoolean(); if (hasBiome && resolved) return; var idValue = document.GetValue("_id", BsonNull.Value); if (idValue.IsBsonNull) return; var coord = document.GetValue("coord", new BsonDocument()).AsBsonDocument; var x = coord.GetValue("x", 0).ToInt32(); var y = coord.GetValue("y", 0).ToInt32(); var biomeKey = hasBiome ? biomeValue.AsString : DetermineBiomeKey(x, y); var locationObject = TryMigrateLegacyResource(document) ?? CreateLocationObjectValueForBiome(biomeKey, x, y); var filter = Builders.Filter.And( Builders.Filter.Eq("_id", idValue) ); var update = Builders.Update .Set("biomeKey", biomeKey) .Set("locationObjectResolved", true) .Set("locationObject", locationObject); await _locations.UpdateOneAsync(filter, update); } 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 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 static string DetermineBiomeKey(int x, int y) { if (x == 0 && y == 0) return "plains"; 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"; } private static BsonValue CreateLocationObjectValueForBiome(string biomeKey, int x, int y) { var roll = Math.Abs(HashCode.Combine(x, y, 1543)) % 100; return biomeKey switch { "forest" => roll switch { < 35 => BsonNull.Value, < 80 => CreateGatherableObjectDocument("wood", 60, 3), < 95 => CreateGatherableObjectDocument("grass", 120, 10), _ => CreateGatherableObjectDocument("stone", 40, 2) }, "rocky" => roll switch { < 60 => BsonNull.Value, < 90 => CreateGatherableObjectDocument("stone", 40, 2), _ => CreateGatherableObjectDocument("wood", 60, 3) }, "wetlands" => roll switch { < 40 => BsonNull.Value, < 90 => CreateGatherableObjectDocument("grass", 120, 10), _ => CreateGatherableObjectDocument("wood", 60, 3) }, "desert" => roll switch { < 70 => BsonNull.Value, < 95 => CreateGatherableObjectDocument("stone", 40, 2), _ => CreateGatherableObjectDocument("wood", 60, 3) }, _ => roll switch { < 50 => BsonNull.Value, < 85 => CreateGatherableObjectDocument("grass", 120, 10), _ => CreateGatherableObjectDocument("wood", 60, 3) } }; } 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 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 int FloorDiv(int value, int divisor) { var quotient = value / divisor; var remainder = value % divisor; if (remainder != 0 && ((remainder < 0) != (divisor < 0))) quotient -= 1; return quotient; } 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; } }