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(); 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("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 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() }, Resources = MapVisibleLocationResources(document) }; } 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; } }