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 } } } } } } } } }, { "biomeKey", new BsonDocument { { "bsonType", new BsonArray { "string", "null" } } } }, { "locationObject", new BsonDocument { { "bsonType", new BsonArray { "object", "null" } }, { "properties", new BsonDocument { { "id", new BsonDocument { { "bsonType", "string" } } }, { "objectType", new BsonDocument { { "bsonType", "string" } } }, { "objectKey", new BsonDocument { { "bsonType", "string" } } }, { "name", new BsonDocument { { "bsonType", "string" } } }, { "state", new BsonDocument { { "bsonType", new BsonArray { "object", "null" } }, { "properties", new BsonDocument { { "itemKey", new BsonDocument { { "bsonType", "string" } } }, { "remainingQuantity", new BsonDocument { { "bsonType", "int" }, { "minimum", 0 } } }, { "gatherQuantity", new BsonDocument { { "bsonType", "int" }, { "minimum", 1 } } } } } } } } } } }, { "locationObjectResolved", new BsonDocument { { "bsonType", new BsonArray { "bool", "null" } } } }, { "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 InteractResult( InteractStatus Status, string ObjectId = "", string ObjectType = "", string ItemKey = "", int QuantityGranted = 0, int RemainingQuantity = 0, bool Consumed = false, LocationObject? PreviousObject = null); public async Task InteractWithObjectAsync(string locationId, string characterId, string objectId, string userId, bool allowAnyOwner) { var location = await _col.Find(l => l.Id == locationId).FirstOrDefaultAsync(); if (location is null) return new InteractResult(InteractStatus.LocationNotFound); location = await EnsureLocationMetadataAsync(location); var character = await _characters.Find(c => c.Id == characterId).FirstOrDefaultAsync(); if (character is null) return new InteractResult(InteractStatus.CharacterNotFound); if (!allowAnyOwner && character.OwnerUserId != userId) return new InteractResult(InteractStatus.Forbidden); if (character.Coord.X != location.Coord.X || character.Coord.Y != location.Coord.Y) return new InteractResult(InteractStatus.Invalid); var locationObject = location.LocationObject; if (locationObject is null) return new InteractResult(InteractStatus.ObjectNotFound); if (!string.Equals(locationObject.Id, objectId, StringComparison.Ordinal)) return new InteractResult(InteractStatus.ObjectNotFound); if (!string.Equals(locationObject.ObjectType, "gatherable", StringComparison.OrdinalIgnoreCase)) return new InteractResult(InteractStatus.UnsupportedObjectType); if (locationObject.State.RemainingQuantity <= 0) return new InteractResult(InteractStatus.ObjectConsumed); var quantityGranted = Math.Min(locationObject.State.GatherQuantity, locationObject.State.RemainingQuantity); var remainingQuantity = locationObject.State.RemainingQuantity - quantityGranted; var objectFilter = Builders.Filter.And( Builders.Filter.Eq(l => l.Id, locationId), Builders.Filter.Eq("locationObject.id", locationObject.Id), Builders.Filter.Eq("locationObject.state.remainingQuantity", locationObject.State.RemainingQuantity) ); UpdateDefinition update; if (remainingQuantity <= 0) { update = Builders.Update.Unset("locationObject"); } else { update = Builders.Update.Set("locationObject.state.remainingQuantity", remainingQuantity); } var result = await _col.UpdateOneAsync(objectFilter, update); if (result.ModifiedCount == 0) return new InteractResult(InteractStatus.ObjectConsumed); return new InteractResult( InteractStatus.Ok, locationObject.Id, locationObject.ObjectType, locationObject.State.ItemKey, quantityGranted, Math.Max(0, remainingQuantity), remainingQuantity <= 0, CloneLocationObject(locationObject)); } public async Task RestoreObjectInteractionAsync(string locationId, LocationObject previousObject) { var filter = Builders.Filter.Eq(l => l.Id, locationId); var update = Builders.Update.Set(l => l.LocationObject, previousObject); 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 }, BiomeKey = DetermineBiomeKey(0, 0), LocationObject = CreateLocationObjectForBiome(DetermineBiomeKey(0, 0), 0, 0), LocationObjectResolved = true, 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(); private async Task EnsureLocationMetadataAsync(Location location) { if (!string.IsNullOrWhiteSpace(location.BiomeKey) && location.LocationObjectResolved) return location; var biomeKey = location.BiomeKey; if (string.IsNullOrWhiteSpace(biomeKey)) biomeKey = DetermineBiomeKey(location.Coord.X, location.Coord.Y); var migratedObject = TryMigrateLegacyResources(location) ?? CreateLocationObjectForBiome(biomeKey, location.Coord.X, location.Coord.Y); var filter = Builders.Filter.And( Builders.Filter.Eq(l => l.Id, location.Id) ); var update = Builders.Update .Set(l => l.BiomeKey, biomeKey) .Set(l => l.LocationObjectResolved, true) .Set(l => l.LocationObject, migratedObject); await _col.UpdateOneAsync(filter, update); location.BiomeKey = biomeKey; location.LocationObject = migratedObject; location.LocationObjectResolved = true; return location; } private static LocationObject? TryMigrateLegacyResources(Location location) { var legacyResource = location.Resources.FirstOrDefault(r => r.RemainingQuantity > 0); if (legacyResource is null) return null; return CreateGatherableObject( legacyResource.ItemKey, legacyResource.RemainingQuantity, legacyResource.GatherQuantity); } 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 LocationObject? CreateLocationObjectForBiome(string biomeKey, int x, int y) { var roll = Math.Abs(HashCode.Combine(x, y, 1543)) % 100; return biomeKey switch { "forest" => roll switch { < 35 => null, < 80 => CreateGatherableObject("wood", 60, 3), < 95 => CreateGatherableObject("grass", 120, 10), _ => CreateGatherableObject("stone", 40, 2) }, "rocky" => roll switch { < 60 => null, < 90 => CreateGatherableObject("stone", 40, 2), _ => CreateGatherableObject("wood", 60, 3) }, "wetlands" => roll switch { < 40 => null, < 90 => CreateGatherableObject("grass", 120, 10), _ => CreateGatherableObject("wood", 60, 3) }, "desert" => roll switch { < 70 => null, < 95 => CreateGatherableObject("stone", 40, 2), _ => CreateGatherableObject("wood", 60, 3) }, _ => roll switch { < 50 => null, < 85 => CreateGatherableObject("grass", 120, 10), _ => CreateGatherableObject("wood", 60, 3) } }; } private static LocationObject CreateGatherableObject(string itemKey, int remainingQuantity, int gatherQuantity) { var normalizedItemKey = NormalizeItemKey(itemKey); return new LocationObject { Id = Guid.NewGuid().ToString("N"), ObjectType = "gatherable", ObjectKey = $"{normalizedItemKey}_node", Name = HumanizeItemKey(normalizedItemKey), State = new LocationObjectState { ItemKey = normalizedItemKey, RemainingQuantity = remainingQuantity, GatherQuantity = gatherQuantity } }; } private static string HumanizeItemKey(string itemKey) { return string.Join(" ", itemKey.Split('_', StringSplitOptions.RemoveEmptyEntries) .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 static LocationObject CloneLocationObject(LocationObject source) { return new LocationObject { Id = source.Id, ObjectType = source.ObjectType, ObjectKey = source.ObjectKey, Name = source.Name, State = new LocationObjectState { ItemKey = source.State.ItemKey, RemainingQuantity = source.State.RemainingQuantity, GatherQuantity = source.State.GatherQuantity } }; } [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 InteractStatus { Ok, LocationNotFound, CharacterNotFound, Forbidden, Invalid, ObjectNotFound, UnsupportedObjectType, ObjectConsumed }