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
456 lines
17 KiB
C#
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;
|
|
}
|
|
}
|