Mailbox support + crafting api
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
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
This commit is contained in:
@@ -0,0 +1,455 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user