Zeeshaun a283969e4c
All checks were successful
Deploy Promiscuity Auth API / deploy (push) Successful in 48s
Deploy Promiscuity Character API / deploy (push) Successful in 46s
Deploy Promiscuity Crafting API / deploy (push) Successful in 1m9s
Deploy Promiscuity Inventory API / deploy (push) Successful in 46s
Deploy Promiscuity Locations API / deploy (push) Successful in 48s
Deploy Promiscuity Mail API / deploy (push) Successful in 46s
k8s smoke test / test (push) Successful in 8s
Mailbox support + crafting api
2026-03-26 09:36:42 -05:00

456 lines
17 KiB
C#

using CraftingApi.Models;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver;
namespace CraftingApi.Services;
public class CraftingStore
{
private readonly IMongoCollection<CraftingRecipe> _recipes;
private readonly IMongoCollection<CharacterDocument> _characters;
private readonly IMongoCollection<LocationDocument> _locations;
private readonly IMongoCollection<InventoryItemDocument> _items;
private readonly IMongoCollection<ItemDefinitionDocument> _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<CraftingRecipe>("CraftingRecipes");
_characters = db.GetCollection<CharacterDocument>("Characters");
_locations = db.GetCollection<LocationDocument>("Locations");
_items = db.GetCollection<InventoryItemDocument>("InventoryItems");
_definitions = db.GetCollection<ItemDefinitionDocument>("ItemDefinitions");
_recipes.Indexes.CreateOne(new CreateIndexModel<CraftingRecipe>(
Builders<CraftingRecipe>.IndexKeys.Ascending(r => r.Category)));
_recipes.Indexes.CreateOne(new CreateIndexModel<CraftingRecipe>(
Builders<CraftingRecipe>.IndexKeys.Ascending(r => r.StationType)));
}
public async Task<CharacterAccessResult> 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<List<CraftingRecipe>> ListRecipesAsync() =>
_recipes.Find(Builders<CraftingRecipe>.Filter.Empty).SortBy(r => r.Category).ThenBy(r => r.Name).ToListAsync();
public async Task<CraftingRecipe?> GetRecipeAsync(string recipeKey) =>
await _recipes.Find(r => r.RecipeKey == NormalizeRecipeKey(recipeKey)).FirstOrDefaultAsync();
public async Task<bool> 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<CraftingRecipe> 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<List<AvailableCraftingRecipeResponse>> 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<CraftAttemptResult> 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<ItemDefinitionDocument>.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<List<InventoryItemDocument>> 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<LocationDocument?> GetLocationAtCoordAsync(int x, int y) =>
await _locations.Find(l => l.Coord.X == x && l.Coord.Y == y).FirstOrDefaultAsync();
private static List<CraftingIngredient> NormalizeIngredients(IEnumerable<CraftingIngredient> 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<string> GetMissingRequirements(CraftingRecipe recipe, IReadOnlyDictionary<string, int> itemTotals, LocationDocument? location, int craftCount = 1)
{
var missing = new List<string>();
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<int?> 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<CraftingIngredient> Consumed { get; set; } = [];
public List<CraftingIngredient> Produced { get; set; } = [];
public List<string> 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;
}
}