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 readonly IMongoCollection _biomeDefinitions; 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"); _biomeDefinitions = db.GetCollection("BiomeDefinitions"); EnsureCoordIndexes(); EnsureOriginLocation(); } private static void EnsureLocationSchema(IMongoDatabase db, string collectionName) { var validator = new BsonDocument { { "$jsonSchema", new BsonDocument { { "bsonType", "object" }, { "required", new BsonArray { "name", "coord", "biomeKey", "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" } } } } } } }, { "biomeKey", new BsonDocument { { "bsonType", "string" } } }, { "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 } } } } } } } } }, { "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.ObjectId, 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.ObjectId), 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.ObjectId, 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); } public async Task GetOrCreateVisibleLocationsAsync(int x, int y, int radius) { var generatedCount = await EnsureVisibleLocationsExistAsync(x, y, radius); var locations = await GetVisibleLocationsAsync(x, y, radius, ensureMetadata: true); return new VisibleLocationWindowResponse { GeneratedCount = generatedCount, Locations = locations }; } public Task> GetBiomeDefinitionsAsync() => _biomeDefinitions.Find(Builders.Filter.Empty) .SortBy(definition => definition.BiomeKey) .ToListAsync(); public async Task GetBiomeDefinitionAsync(string biomeKey) { var normalizedBiomeKey = NormalizeBiomeKey(biomeKey); return await _biomeDefinitions.Find(definition => definition.BiomeKey == normalizedBiomeKey) .FirstOrDefaultAsync(); } public async Task CreateBiomeDefinitionAsync(BiomeDefinition definition) { definition.BiomeKey = NormalizeBiomeKey(definition.BiomeKey); definition.TransitionWeights = NormalizeTransitionWeights(definition.TransitionWeights); definition.ObjectSpawnRules = NormalizeObjectSpawnRules(definition.ObjectSpawnRules); definition.UpdatedUtc = DateTime.UtcNow; var existing = await GetBiomeDefinitionAsync(definition.BiomeKey); if (existing is not null) return false; await _biomeDefinitions.InsertOneAsync(definition); return true; } public async Task UpsertBiomeDefinitionAsync(string biomeKey, UpsertBiomeDefinitionRequest request) { var normalizedBiomeKey = NormalizeBiomeKey(biomeKey); var definition = new BiomeDefinition { BiomeKey = normalizedBiomeKey, ContinuationWeight = request.ContinuationWeight, TransitionWeights = NormalizeTransitionWeights(request.TransitionWeights), ObjectSpawnRules = NormalizeObjectSpawnRules(request.ObjectSpawnRules), UpdatedUtc = DateTime.UtcNow }; var filter = Builders.Filter.Eq(existing => existing.BiomeKey, normalizedBiomeKey); await _biomeDefinitions.ReplaceOneAsync(filter, definition, new ReplaceOptions { IsUpsert = true }); return definition; } 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 biomeDefinitions = LoadBiomeDefinitions(); if (biomeDefinitions.Count == 0) return; var originBiomeKey = biomeDefinitions.Any(definition => definition.BiomeKey == "plains") ? "plains" : biomeDefinitions[0].BiomeKey; 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 = originBiomeKey, LocationObject = CreateLocationObjectForBiome(biomeDefinitions, originBiomeKey, 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> GetVisibleLocationsAsync(int x, int y, int radius, bool ensureMetadata) { var minX = x - radius; var maxX = x + radius; var minY = y - radius; var maxY = y + radius; var filter = Builders.Filter.And( Builders.Filter.Gte(location => location.Coord.X, minX), Builders.Filter.Lte(location => location.Coord.X, maxX), Builders.Filter.Gte(location => location.Coord.Y, minY), Builders.Filter.Lte(location => location.Coord.Y, maxY) ); var locations = await _col.Find(filter).ToListAsync(); if (ensureMetadata) { for (var index = 0; index < locations.Count; index++) locations[index] = await EnsureLocationMetadataAsync(locations[index]); } return locations.Select(MapVisibleLocation).ToList(); } private async Task EnsureVisibleLocationsExistAsync(int x, int y, int radius) { var biomeDefinitions = await LoadBiomeDefinitionsAsync(); var generatedCount = 0; for (var currentX = x - radius; currentX <= x + radius; currentX++) { for (var currentY = y - radius; currentY <= y + radius; currentY++) { if (await EnsureLocationStateAsync(currentX, currentY, biomeDefinitions)) generatedCount += 1; } } return generatedCount; } private async Task EnsureLocationStateAsync(int x, int y, IReadOnlyList biomeDefinitions) { var filter = Builders.Filter.And( Builders.Filter.Eq("coord.x", x), Builders.Filter.Eq("coord.y", y) ); var existing = await _rawCol.Find(filter).FirstOrDefaultAsync(); if (existing is not null) { var typedLocation = await _col.Find(location => location.Id == existing["_id"].AsObjectId.ToString()).FirstOrDefaultAsync(); if (typedLocation is not null) await EnsureLocationMetadataAsync(typedLocation); return false; } var biomeKey = await DetermineBiomeKeyAsync(x, y, biomeDefinitions); var locationObject = CreateLocationObjectForBiome(biomeDefinitions, biomeKey, x, y); BsonValue locationObjectValue = locationObject is null ? BsonNull.Value : locationObject.ToBsonDocument(); 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", locationObjectValue) .SetOnInsert("locationObjectResolved", true) .SetOnInsert("createdUtc", DateTime.UtcNow); try { var result = await _rawCol.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 EnsureLocationMetadataAsync(Location location) { if (!string.IsNullOrWhiteSpace(location.BiomeKey) && location.LocationObjectResolved) return location; var biomeDefinitions = await LoadBiomeDefinitionsAsync(); var biomeKey = location.BiomeKey; if (string.IsNullOrWhiteSpace(biomeKey)) biomeKey = await DetermineBiomeKeyAsync(location.Coord.X, location.Coord.Y, biomeDefinitions); var migratedObject = TryMigrateLegacyResources(location) ?? CreateLocationObjectForBiome(biomeDefinitions, 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 VisibleLocationResponse MapVisibleLocation(Location location) { return new VisibleLocationResponse { Id = location.Id, Name = location.Name, Coord = new Coord { X = location.Coord.X, Y = location.Coord.Y }, BiomeKey = location.BiomeKey, LocationObject = MapVisibleLocationObject(location.LocationObject) }; } private static VisibleLocationObjectResponse? MapVisibleLocationObject(LocationObject? locationObject) { if (locationObject is null) return null; return new VisibleLocationObjectResponse { Id = locationObject.ObjectId, ObjectType = locationObject.ObjectType, ObjectKey = locationObject.ObjectKey, Name = locationObject.Name, State = new VisibleLocationObjectStateResponse { ItemKey = locationObject.State.ItemKey, RemainingQuantity = locationObject.State.RemainingQuantity, GatherQuantity = locationObject.State.GatherQuantity } }; } 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 async Task DetermineBiomeKeyAsync(int x, int y, IReadOnlyList biomeDefinitions) { if (x == 0 && y == 0) return biomeDefinitions.Any(definition => definition.BiomeKey == "plains") ? "plains" : biomeDefinitions[0].BiomeKey; var neighbors = await LoadNeighborBiomeKeysAsync(x, y); var baseBiome = DetermineBaseBiomeKey(x, y); if (neighbors.Count == 0) return biomeDefinitions.Any(definition => definition.BiomeKey == baseBiome) ? baseBiome : biomeDefinitions[0].BiomeKey; 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 biomeDefinitions) { var score = candidate.BiomeKey == baseBiome ? 2.5 : 0.35; if (candidate.BiomeKey == dominantNeighbor) score += 1.8; foreach (var neighbor in neighbors) { var neighborDefinition = biomeDefinitions.FirstOrDefault(definition => definition.BiomeKey == neighbor); if (neighborDefinition is null) continue; if (candidate.BiomeKey == neighbor) { score += neighborDefinition.ContinuationWeight; continue; } var transition = neighborDefinition.TransitionWeights .FirstOrDefault(weight => weight.TargetBiomeKey == candidate.BiomeKey); if (transition is not null) score += transition.Weight; } score += StableNoise(x, y, StableHash(candidate.BiomeKey)) * 0.25; if (score > bestScore) { bestScore = score; bestBiome = candidate.BiomeKey; } } return bestBiome; } private static LocationObject? CreateLocationObjectForBiome(IReadOnlyList biomeDefinitions, string biomeKey, int x, int y) { var biome = biomeDefinitions.FirstOrDefault(definition => definition.BiomeKey == biomeKey) ?? throw new InvalidOperationException($"Missing biome definition for '{biomeKey}'."); var totalWeight = biome.ObjectSpawnRules.Sum(rule => rule.Weight); if (totalWeight <= 0) return null; var roll = StableNoise(x, y, 401) * totalWeight; var cumulative = 0.0; foreach (var rule in biome.ObjectSpawnRules) { cumulative += rule.Weight; if (roll > cumulative) continue; if (string.Equals(rule.ResultType, "none", StringComparison.OrdinalIgnoreCase)) return null; if (!string.Equals(rule.ResultType, "gatherable", StringComparison.OrdinalIgnoreCase) || string.IsNullOrWhiteSpace(rule.ItemKey)) return null; return CreateGatherableObject( rule.ItemKey, Math.Max(0, rule.RemainingQuantity), Math.Max(1, rule.GatherQuantity), rule.ObjectKey, rule.DisplayName); } return null; } private static LocationObject CreateGatherableObject(string itemKey, int remainingQuantity, int gatherQuantity, string? objectKey = null, string? displayName = null) { var normalizedItemKey = NormalizeItemKey(itemKey); return new LocationObject { ObjectId = Guid.NewGuid().ToString("N"), ObjectType = "gatherable", ObjectKey = string.IsNullOrWhiteSpace(objectKey) ? $"{normalizedItemKey}_node" : objectKey, Name = string.IsNullOrWhiteSpace(displayName) ? HumanizeItemKey(normalizedItemKey) : displayName, 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 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 _rawCol.Find(filter).ToListAsync(); return neighbors .Where(doc => doc.Contains("biomeKey")) .Select(doc => doc.GetValue("biomeKey", "").AsString) .Where(key => !string.IsNullOrWhiteSpace(key)) .ToList(); } private List LoadBiomeDefinitions() { return _biomeDefinitions.Find(Builders.Filter.Empty) .SortBy(definition => definition.BiomeKey) .ToList(); } private async Task> LoadBiomeDefinitionsAsync() { var definitions = await _biomeDefinitions.Find(Builders.Filter.Empty) .SortBy(definition => definition.BiomeKey) .ToListAsync(); if (definitions.Count == 0) throw new InvalidOperationException("No biome definitions exist in the BiomeDefinitions collection."); return definitions; } 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 double StableNoise(int x, int y, int salt) { var value = Math.Sin((x * 12.9898) + (y * 78.233) + ((1729 + 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; } } private static string NormalizeBiomeKey(string biomeKey) => biomeKey.Trim().ToLowerInvariant(); private static List NormalizeTransitionWeights(IEnumerable transitionWeights) { return transitionWeights .Where(weight => !string.IsNullOrWhiteSpace(weight.TargetBiomeKey)) .Select(weight => new BiomeTransitionWeight { TargetBiomeKey = NormalizeBiomeKey(weight.TargetBiomeKey), Weight = weight.Weight }) .ToList(); } private static List NormalizeObjectSpawnRules(IEnumerable objectSpawnRules) { return objectSpawnRules.Select(rule => new BiomeObjectSpawnRule { ResultType = string.IsNullOrWhiteSpace(rule.ResultType) ? "none" : rule.ResultType.Trim().ToLowerInvariant(), ItemKey = string.IsNullOrWhiteSpace(rule.ItemKey) ? null : NormalizeItemKey(rule.ItemKey), ObjectKey = string.IsNullOrWhiteSpace(rule.ObjectKey) ? null : rule.ObjectKey.Trim(), DisplayName = string.IsNullOrWhiteSpace(rule.DisplayName) ? null : rule.DisplayName.Trim(), RemainingQuantity = rule.RemainingQuantity, GatherQuantity = rule.GatherQuantity, Weight = rule.Weight }).ToList(); } private static string DefaultLocationName(int x, int y) { if (x == 0 && y == 0) return "Origin"; return $"Location {x},{y}"; } private static LocationObject CloneLocationObject(LocationObject source) { return new LocationObject { ObjectId = source.ObjectId, 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 CharacterCoord Coord { get; set; } = new(); } [MongoDB.Bson.Serialization.Attributes.BsonIgnoreExtraElements] private class CharacterCoord { public int X { get; set; } public int Y { get; set; } } } public enum InteractStatus { Ok, LocationNotFound, CharacterNotFound, Forbidden, Invalid, ObjectNotFound, UnsupportedObjectType, ObjectConsumed }