using LocationsApi.Models; using MongoDB.Bson; using MongoDB.Driver; namespace LocationsApi.Services; public class LocationStore { private readonly IMongoCollection _col; private readonly IMongoCollection _rawCol; private readonly IMongoCollection _characters; private const string CoordIndexName = "coord_x_1_coord_y_1"; public LocationStore(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); var collectionName = "Locations"; EnsureLocationSchema(db, collectionName); _col = db.GetCollection(collectionName); _rawCol = db.GetCollection(collectionName); _characters = db.GetCollection("Characters"); EnsureCoordIndexes(); EnsureOriginLocation(); } private static void EnsureLocationSchema(IMongoDatabase db, string collectionName) { var validator = new BsonDocument { { "$jsonSchema", new BsonDocument { { "bsonType", "object" }, { "required", new BsonArray { "name", "coord", "createdUtc" } }, { "properties", new BsonDocument { { "name", new BsonDocument { { "bsonType", "string" } } }, { "coord", new BsonDocument { { "bsonType", "object" }, { "required", new BsonArray { "x", "y" } }, { "properties", new BsonDocument { { "x", new BsonDocument { { "bsonType", "int" } } }, { "y", new BsonDocument { { "bsonType", "int" } } } } } } }, { "resources", new BsonDocument { { "bsonType", new BsonArray { "array", "null" } }, { "items", new BsonDocument { { "bsonType", "object" }, { "required", new BsonArray { "itemKey", "remainingQuantity", "gatherQuantity" } }, { "properties", new BsonDocument { { "itemKey", new BsonDocument { { "bsonType", "string" } } }, { "remainingQuantity", new BsonDocument { { "bsonType", "int" }, { "minimum", 0 } } }, { "gatherQuantity", new BsonDocument { { "bsonType", "int" }, { "minimum", 1 } } } } } } } } }, { "createdUtc", new BsonDocument { { "bsonType", "date" } } } } } } } }; var collections = db.ListCollectionNames().ToList(); if (!collections.Contains(collectionName)) { var createCommand = new BsonDocument { { "create", collectionName }, { "validator", validator }, { "validationAction", "error" } }; db.RunCommand(createCommand); return; } var command = new BsonDocument { { "collMod", collectionName }, { "validator", validator }, { "validationAction", "error" } }; db.RunCommand(command); } public Task CreateAsync(Location location) => _col.InsertOneAsync(location); public Task> GetAllAsync() => _col.Find(Builders.Filter.Empty).ToListAsync(); public async Task DeleteAsync(string id) { var filter = Builders.Filter.Eq(l => l.Id, id); var result = await _col.DeleteOneAsync(filter); return result.DeletedCount > 0; } public async Task UpdateNameAsync(string id, string name) { var filter = Builders.Filter.Eq(l => l.Id, id); var update = Builders.Update.Set(l => l.Name, name); var result = await _col.UpdateOneAsync(filter, update); return result.ModifiedCount > 0; } public sealed record GatherResult(GatherStatus Status, string ResourceKey = "", int QuantityGranted = 0, int RemainingQuantity = 0); public async Task GatherResourceAsync(string locationId, string characterId, string resourceKey, string userId, bool allowAnyOwner) { var normalizedKey = resourceKey.Trim().ToLowerInvariant(); var location = await _col.Find(l => l.Id == locationId).FirstOrDefaultAsync(); if (location is null) return new GatherResult(GatherStatus.LocationNotFound); var character = await _characters.Find(c => c.Id == characterId).FirstOrDefaultAsync(); if (character is null) return new GatherResult(GatherStatus.CharacterNotFound); if (!allowAnyOwner && character.OwnerUserId != userId) return new GatherResult(GatherStatus.Forbidden); if (character.Coord.X != location.Coord.X || character.Coord.Y != location.Coord.Y) return new GatherResult(GatherStatus.Invalid); var resource = location.Resources.FirstOrDefault(r => NormalizeItemKey(r.ItemKey) == normalizedKey); if (resource is null) return new GatherResult(GatherStatus.ResourceNotFound); if (resource.RemainingQuantity <= 0) return new GatherResult(GatherStatus.ResourceDepleted); var quantityGranted = Math.Min(resource.GatherQuantity, resource.RemainingQuantity); var filter = Builders.Filter.And( Builders.Filter.Eq(l => l.Id, locationId), Builders.Filter.ElemMatch(l => l.Resources, r => r.ItemKey == resource.ItemKey && r.RemainingQuantity >= quantityGranted) ); var update = Builders.Update.Inc("resources.$.remainingQuantity", -quantityGranted); var result = await _col.UpdateOneAsync(filter, update); if (result.ModifiedCount == 0) return new GatherResult(GatherStatus.ResourceDepleted); return new GatherResult( GatherStatus.Ok, resource.ItemKey, quantityGranted, resource.RemainingQuantity - quantityGranted); } public async Task RestoreGatheredResourceAsync(string locationId, string resourceKey, int quantity) { var normalizedKey = NormalizeItemKey(resourceKey); var filter = Builders.Filter.And( Builders.Filter.Eq(l => l.Id, locationId), Builders.Filter.ElemMatch(l => l.Resources, r => r.ItemKey == normalizedKey) ); var update = Builders.Update.Inc("resources.$.remainingQuantity", quantity); await _col.UpdateOneAsync(filter, update); } private void EnsureCoordIndexes() { var indexes = _rawCol.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)) _rawCol.Indexes.DropOne(name); } var coordIndex = new BsonDocument { { "coord.x", 1 }, { "coord.y", 1 } }; var coordIndexOptions = new CreateIndexOptions { Unique = true, Name = CoordIndexName }; _rawCol.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"))); } private void EnsureOriginLocation() { var filter = Builders.Filter.And( Builders.Filter.Eq(l => l.Coord.X, 0), Builders.Filter.Eq(l => l.Coord.Y, 0) ); var existing = _col.Find(filter).FirstOrDefault(); if (existing is not null) return; var origin = new Location { Name = "Origin", Coord = new Coord { X = 0, Y = 0 }, Resources = [ new LocationResource { ItemKey = "wood", RemainingQuantity = 100, GatherQuantity = 3 }, new LocationResource { ItemKey = "grass", RemainingQuantity = 500, GatherQuantity = 10 } ], CreatedUtc = DateTime.UtcNow }; try { _col.InsertOne(origin); } catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) { // Another instance seeded it first. } } private static string NormalizeItemKey(string itemKey) => itemKey.Trim().ToLowerInvariant(); [MongoDB.Bson.Serialization.Attributes.BsonIgnoreExtraElements] private class CharacterDocument { [MongoDB.Bson.Serialization.Attributes.BsonId] [MongoDB.Bson.Serialization.Attributes.BsonRepresentation(MongoDB.Bson.BsonType.ObjectId)] public string? Id { get; set; } public string OwnerUserId { get; set; } = string.Empty; public Coord Coord { get; set; } = new(); } } public enum GatherStatus { Ok, LocationNotFound, CharacterNotFound, Forbidden, Invalid, ResourceNotFound, ResourceDepleted }