using CraftingApi.Models; using MongoDB.Bson; using MongoDB.Bson.Serialization.Attributes; using MongoDB.Driver; namespace CraftingApi.Services; public class CraftingStore { private readonly IMongoCollection _recipes; private readonly IMongoCollection _characters; private readonly IMongoCollection _locations; private readonly IMongoCollection _items; private readonly IMongoCollection _definitions; private const string CharacterOwnerType = "character"; public CraftingStore(IConfiguration cfg) { var cs = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017"; var dbName = cfg["MongoDB:DatabaseName"] ?? "promiscuity"; var client = new MongoClient(cs); var db = client.GetDatabase(dbName); _recipes = db.GetCollection("CraftingRecipes"); _characters = db.GetCollection("Characters"); _locations = db.GetCollection("Locations"); _items = db.GetCollection("InventoryItems"); _definitions = db.GetCollection("ItemDefinitions"); _recipes.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(r => r.Category))); _recipes.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(r => r.StationType))); } public async Task ResolveCharacterAsync(string characterId, string userId, bool allowAnyOwner) { var character = await _characters.Find(c => c.Id == characterId).FirstOrDefaultAsync(); if (character is null) return new CharacterAccessResult { Exists = false }; return new CharacterAccessResult { Exists = true, IsAuthorized = allowAnyOwner || character.OwnerUserId == userId, CharacterId = character.Id ?? string.Empty, OwnerUserId = character.OwnerUserId, CoordX = character.Coord.X, CoordY = character.Coord.Y }; } public Task> ListRecipesAsync() => _recipes.Find(Builders.Filter.Empty).SortBy(r => r.Category).ThenBy(r => r.Name).ToListAsync(); public async Task GetRecipeAsync(string recipeKey) => await _recipes.Find(r => r.RecipeKey == NormalizeRecipeKey(recipeKey)).FirstOrDefaultAsync(); public async Task CreateRecipeAsync(CraftingRecipe recipe) { var existing = await GetRecipeAsync(recipe.RecipeKey); if (existing is not null) return false; await _recipes.InsertOneAsync(recipe); return true; } public CraftingRecipe BuildRecipe(string recipeKey, UpsertCraftingRecipeRequest request) { return new CraftingRecipe { RecipeKey = NormalizeRecipeKey(recipeKey), Name = request.Name.Trim(), Category = NormalizeSimpleKey(request.Category, "misc"), StationType = NormalizeSimpleKey(request.StationType, "hand"), Inputs = NormalizeIngredients(request.Inputs), Outputs = NormalizeIngredients(request.Outputs), CraftTimeSeconds = Math.Max(0, request.CraftTimeSeconds), Enabled = request.Enabled, UpdatedUtc = DateTime.UtcNow }; } public async Task UpsertRecipeAsync(string recipeKey, UpsertCraftingRecipeRequest request) { var normalizedRecipeKey = NormalizeRecipeKey(recipeKey); var recipe = BuildRecipe(normalizedRecipeKey, request); var existing = await GetRecipeAsync(normalizedRecipeKey); if (existing is not null) recipe.CreatedUtc = existing.CreatedUtc; await _recipes.ReplaceOneAsync(r => r.RecipeKey == normalizedRecipeKey, recipe, new ReplaceOptions { IsUpsert = true }); return recipe; } public async Task> GetAvailableRecipesAsync(CharacterAccessResult character) { var recipes = await _recipes.Find(r => r.Enabled).SortBy(r => r.Category).ThenBy(r => r.Name).ToListAsync(); var items = await GetCharacterItemsAsync(character.CharacterId); var itemTotals = items.GroupBy(i => i.ItemKey).ToDictionary(g => g.Key, g => g.Sum(x => x.Quantity), StringComparer.Ordinal); var location = await GetLocationAtCoordAsync(character.CoordX, character.CoordY); return recipes.Select(recipe => { var missing = GetMissingRequirements(recipe, itemTotals, location); return new AvailableCraftingRecipeResponse { Recipe = CraftingRecipeResponse.FromModel(recipe), CanCraft = missing.Count == 0, MissingRequirements = missing }; }).ToList(); } public async Task CraftAsync(CharacterAccessResult character, CraftRecipeRequest request) { var recipe = await GetRecipeAsync(request.RecipeKey); if (recipe is null || !recipe.Enabled) return new CraftAttemptResult { Status = CraftStatus.RecipeNotFound }; var craftCount = Math.Max(1, request.Quantity); var location = await GetLocationAtCoordAsync(character.CoordX, character.CoordY); if (!CanUseStation(recipe, location, request.LocationId, request.StationObjectId)) return new CraftAttemptResult { Status = CraftStatus.MissingStation }; var items = await GetCharacterItemsAsync(character.CharacterId); var totals = items.GroupBy(i => i.ItemKey).ToDictionary(g => g.Key, g => g.Sum(x => x.Quantity), StringComparer.Ordinal); var missing = GetMissingRequirements(recipe, totals, location, craftCount); if (missing.Count > 0) return new CraftAttemptResult { Status = CraftStatus.MissingInputs, MissingRequirements = missing }; var definitions = await _definitions.Find(Builders.Filter.Empty).ToListAsync(); var definitionMap = definitions.ToDictionary(d => d.ItemKey, StringComparer.Ordinal); foreach (var output in recipe.Outputs) { if (!definitionMap.ContainsKey(output.ItemKey)) return new CraftAttemptResult { Status = CraftStatus.InvalidRecipe, MissingRequirements = [$"Missing item definition for output '{output.ItemKey}'"] }; } foreach (var input in recipe.Inputs) await ConsumeItemKeyAsync(character, input.ItemKey, input.Quantity * craftCount); foreach (var output in recipe.Outputs) await GrantItemKeyAsync(character, output.ItemKey, output.Quantity * craftCount, definitionMap[output.ItemKey]); return new CraftAttemptResult { Status = CraftStatus.Ok, CraftedQuantity = craftCount, Consumed = recipe.Inputs.Select(i => new CraftingIngredient { ItemKey = i.ItemKey, Quantity = i.Quantity * craftCount }).ToList(), Produced = recipe.Outputs.Select(i => new CraftingIngredient { ItemKey = i.ItemKey, Quantity = i.Quantity * craftCount }).ToList() }; } private async Task> GetCharacterItemsAsync(string characterId) => await _items.Find(i => i.OwnerType == CharacterOwnerType && i.OwnerId == characterId).SortBy(i => i.Slot).ThenBy(i => i.ItemKey).ToListAsync(); private async Task GetLocationAtCoordAsync(int x, int y) => await _locations.Find(l => l.Coord.X == x && l.Coord.Y == y).FirstOrDefaultAsync(); private static List NormalizeIngredients(IEnumerable ingredients) { return ingredients .Where(i => !string.IsNullOrWhiteSpace(i.ItemKey) && i.Quantity > 0) .Select(i => new CraftingIngredient { ItemKey = NormalizeSimpleKey(i.ItemKey, ""), Quantity = i.Quantity }) .ToList(); } private static string NormalizeRecipeKey(string recipeKey) => NormalizeSimpleKey(recipeKey, ""); private static string NormalizeSimpleKey(string value, string fallback) { var normalized = value.Trim().ToLowerInvariant(); return string.IsNullOrWhiteSpace(normalized) ? fallback : normalized; } private static List GetMissingRequirements(CraftingRecipe recipe, IReadOnlyDictionary itemTotals, LocationDocument? location, int craftCount = 1) { var missing = new List(); if (!CanUseStation(recipe, location, null, null)) missing.Add("Required station is not available"); foreach (var input in recipe.Inputs) { var required = input.Quantity * craftCount; var available = itemTotals.TryGetValue(input.ItemKey, out var amount) ? amount : 0; if (available < required) missing.Add("Need %s x%d".Replace("%s", input.ItemKey).Replace("%d", required.ToString())); } return missing; } private static bool CanUseStation(CraftingRecipe recipe, LocationDocument? location, string? requestedLocationId, string? requestedStationObjectId) { if (recipe.StationType == "hand") return true; if (location is null) return false; if (!string.IsNullOrWhiteSpace(requestedLocationId) && !string.Equals(location.Id, requestedLocationId, StringComparison.Ordinal)) return false; if (location.LocationObject is null) return false; if (!string.IsNullOrWhiteSpace(requestedStationObjectId) && !string.Equals(location.LocationObject.ObjectId, requestedStationObjectId, StringComparison.Ordinal)) return false; if (!string.Equals(location.LocationObject.ObjectType, "station", StringComparison.OrdinalIgnoreCase)) return false; var stationType = NormalizeSimpleKey(location.LocationObject.State.StationType ?? string.Empty, ""); return string.Equals(recipe.StationType, stationType, StringComparison.Ordinal); } private async Task ConsumeItemKeyAsync(CharacterAccessResult character, string itemKey, int quantity) { var remaining = quantity; var items = await _items.Find(i => i.OwnerType == CharacterOwnerType && i.OwnerId == character.CharacterId && i.ItemKey == itemKey && i.EquippedSlot == null) .SortBy(i => i.Slot) .ThenBy(i => i.CreatedUtc) .ToListAsync(); foreach (var item in items) { if (remaining <= 0) break; if (item.Quantity <= remaining) { remaining -= item.Quantity; await _items.DeleteOneAsync(i => i.Id == item.Id); } else { item.Quantity -= remaining; item.UpdatedUtc = DateTime.UtcNow; await _items.ReplaceOneAsync(i => i.Id == item.Id, item); remaining = 0; } } } private async Task GrantItemKeyAsync(CharacterAccessResult character, string itemKey, int quantity, ItemDefinitionDocument definition) { var remaining = quantity; if (definition.Stackable) { var existingStacks = await _items.Find(i => i.OwnerType == CharacterOwnerType && i.OwnerId == character.CharacterId && i.ItemKey == itemKey && i.EquippedSlot == null && i.Slot != null) .SortBy(i => i.Slot) .ToListAsync(); foreach (var stack in existingStacks) { if (remaining <= 0) break; var availableSpace = definition.MaxStackSize - stack.Quantity; if (availableSpace <= 0) continue; var added = Math.Min(remaining, availableSpace); stack.Quantity += added; stack.UpdatedUtc = DateTime.UtcNow; await _items.ReplaceOneAsync(i => i.Id == stack.Id, stack); remaining -= added; } } while (remaining > 0) { var slot = await FindFirstOpenSlotAsync(character.CharacterId); if (slot is null) throw new InvalidOperationException("Crafting outputs did not fit in the character inventory."); var granted = definition.Stackable ? Math.Min(remaining, definition.MaxStackSize) : 1; var item = new InventoryItemDocument { ItemKey = itemKey, Quantity = granted, OwnerType = CharacterOwnerType, OwnerId = character.CharacterId, OwnerUserId = character.OwnerUserId, Slot = slot.Value, CreatedUtc = DateTime.UtcNow, UpdatedUtc = DateTime.UtcNow }; await _items.InsertOneAsync(item); remaining -= granted; } } private async Task FindFirstOpenSlotAsync(string characterId) { var items = await GetCharacterItemsAsync(characterId); var used = items.Where(i => i.Slot.HasValue).Select(i => i.Slot!.Value).ToHashSet(); for (var slot = 0; slot < 6; slot++) { if (!used.Contains(slot)) return slot; } return null; } public class CharacterAccessResult { public bool Exists { get; set; } public bool IsAuthorized { get; set; } public string CharacterId { get; set; } = string.Empty; public string OwnerUserId { get; set; } = string.Empty; public int CoordX { get; set; } public int CoordY { get; set; } } public class CraftAttemptResult { public CraftStatus Status { get; set; } public int CraftedQuantity { get; set; } public List Consumed { get; set; } = []; public List Produced { get; set; } = []; public List MissingRequirements { get; set; } = []; } public enum CraftStatus { Ok, RecipeNotFound, MissingInputs, MissingStation, InvalidRecipe } [BsonIgnoreExtraElements] private class CharacterDocument { [BsonId] [BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; } public string OwnerUserId { get; set; } = string.Empty; public CoordDocument Coord { get; set; } = new(); } [BsonIgnoreExtraElements] private class CoordDocument { public int X { get; set; } public int Y { get; set; } } [BsonIgnoreExtraElements] private class LocationDocument { [BsonId] [BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; } [BsonElement("coord")] public CoordDocument Coord { get; set; } = new(); [BsonElement("locationObject")] public LocationObjectDocument? LocationObject { get; set; } } [BsonIgnoreExtraElements] private class LocationObjectDocument { [BsonElement("id")] public string ObjectId { get; set; } = string.Empty; [BsonElement("objectType")] public string ObjectType { get; set; } = string.Empty; [BsonElement("state")] public LocationObjectStateDocument State { get; set; } = new(); } [BsonIgnoreExtraElements] private class LocationObjectStateDocument { [BsonElement("stationType")] [BsonIgnoreIfNull] public string? StationType { get; set; } } [BsonIgnoreExtraElements] private class InventoryItemDocument { [BsonId] public string Id { get; set; } = Guid.NewGuid().ToString(); [BsonElement("itemKey")] public string ItemKey { get; set; } = string.Empty; [BsonElement("quantity")] public int Quantity { get; set; } [BsonElement("ownerType")] public string OwnerType { get; set; } = string.Empty; [BsonElement("ownerId")] public string OwnerId { get; set; } = string.Empty; [BsonElement("ownerUserId")] [BsonIgnoreIfNull] public string? OwnerUserId { get; set; } [BsonElement("slot")] [BsonIgnoreIfNull] public int? Slot { get; set; } [BsonElement("equippedSlot")] [BsonIgnoreIfNull] public string? EquippedSlot { get; set; } [BsonElement("createdUtc")] public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; [BsonElement("updatedUtc")] public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; } [BsonIgnoreExtraElements] private class ItemDefinitionDocument { [BsonId] [BsonElement("itemKey")] public string ItemKey { get; set; } = string.Empty; [BsonElement("stackable")] public bool Stackable { get; set; } [BsonElement("maxStackSize")] public int MaxStackSize { get; set; } = 1; } }