Mailbox support + crafting api
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

This commit is contained in:
Zeeshaun 2026-03-26 09:36:42 -05:00
parent a8db66b93e
commit a283969e4c
22 changed files with 1026 additions and 17 deletions

View File

@ -0,0 +1,78 @@
name: Deploy Promiscuity Crafting API
on:
push:
branches:
- main
workflow_dispatch: {}
jobs:
deploy:
runs-on: self-hosted
env:
IMAGE_NAME: promiscuity-crafting:latest
IMAGE_TAR: /tmp/promiscuity-crafting.tar
NODES: "192.168.86.72 192.168.86.73 192.168.86.74"
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Build Docker image
run: |
cd microservices/CraftingApi
docker build -t "${IMAGE_NAME}" .
- name: Save Docker image to TAR
run: |
docker save "${IMAGE_NAME}" -o "${IMAGE_TAR}"
- name: Copy TAR to nodes
run: |
for node in ${NODES}; do
scp -o StrictHostKeyChecking=no "${IMAGE_TAR}" hz@"$node":/tmp/promiscuity-crafting.tar
done
- name: Import image on nodes
run: |
for node in ${NODES}; do
ssh -o StrictHostKeyChecking=no hz@"$node" "sudo ctr -n k8s.io images import /tmp/promiscuity-crafting.tar"
done
- name: Clean TAR from nodes
run: |
for node in ${NODES}; do
ssh -o StrictHostKeyChecking=no hz@"$node" "rm -f /tmp/promiscuity-crafting.tar"
done
- name: Clean TAR on runner
run: |
rm -f "${IMAGE_TAR}"
- name: Write kubeconfig from secret
env:
KUBECONFIG_CONTENT: ${{ secrets.KUBECONFIG }}
run: |
mkdir -p /tmp/kube
printf '%s\n' "$KUBECONFIG_CONTENT" > /tmp/kube/config
- name: Create namespace if missing
env:
KUBECONFIG: /tmp/kube/config
run: |
kubectl create namespace promiscuity-crafting --dry-run=client -o yaml | kubectl apply -f -
- name: Apply Crafting deployment & service
env:
KUBECONFIG: /tmp/kube/config
run: |
kubectl apply -f microservices/CraftingApi/k8s/deployment.yaml -n promiscuity-crafting
kubectl apply -f microservices/CraftingApi/k8s/service.yaml -n promiscuity-crafting
- name: Restart Crafting deployment
env:
KUBECONFIG: /tmp/kube/config
run: |
kubectl rollout restart deployment/promiscuity-crafting -n promiscuity-crafting
kubectl rollout status deployment/promiscuity-crafting -n promiscuity-crafting

View File

@ -1032,15 +1032,15 @@ func _parse_mail_messages(value: Variant) -> Array:
continue continue
var message := entry as Dictionary var message := entry as Dictionary
messages.append({ messages.append({
"id": String(message.get("id", "")).strip_edges(), "id": str(message.get("id", "")).strip_edges(),
"senderCharacterId": String(message.get("senderCharacterId", "")).strip_edges(), "senderCharacterId": str(message.get("senderCharacterId", "")).strip_edges(),
"senderCharacterName": String(message.get("senderCharacterName", "")).strip_edges(), "senderCharacterName": str(message.get("senderCharacterName", "")).strip_edges(),
"recipientCharacterId": String(message.get("recipientCharacterId", "")).strip_edges(), "recipientCharacterId": str(message.get("recipientCharacterId", "")).strip_edges(),
"recipientCharacterName": String(message.get("recipientCharacterName", "")).strip_edges(), "recipientCharacterName": str(message.get("recipientCharacterName", "")).strip_edges(),
"subject": String(message.get("subject", "")).strip_edges(), "subject": str(message.get("subject", "")).strip_edges(),
"body": String(message.get("body", "")).strip_edges(), "body": str(message.get("body", "")).strip_edges(),
"createdUtc": String(message.get("createdUtc", "")).strip_edges(), "createdUtc": str(message.get("createdUtc", "")).strip_edges(),
"readUtc": String(message.get("readUtc", "")).strip_edges() "readUtc": str(message.get("readUtc", "")).strip_edges()
}) })
return messages return messages

View File

@ -99,9 +99,9 @@ anchor_bottom = 1.0
grow_horizontal = 2 grow_horizontal = 2
grow_vertical = 2 grow_vertical = 2
theme_override_constants/margin_left = 48 theme_override_constants/margin_left = 48
theme_override_constants/margin_top = 48 theme_override_constants/margin_top = 24
theme_override_constants/margin_right = 48 theme_override_constants/margin_right = 48
theme_override_constants/margin_bottom = 48 theme_override_constants/margin_bottom = 24
[node name="Panel" type="PanelContainer" parent="InventoryMenu/MarginContainer"] [node name="Panel" type="PanelContainer" parent="InventoryMenu/MarginContainer"]
layout_mode = 2 layout_mode = 2
@ -282,7 +282,7 @@ theme_override_constants/separation = 12
[node name="InboxPanel" type="PanelContainer" parent="MailMenu/MarginContainer/Panel/VBoxContainer/Columns"] [node name="InboxPanel" type="PanelContainer" parent="MailMenu/MarginContainer/Panel/VBoxContainer/Columns"]
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 2 size_flags_horizontal = 3
size_flags_vertical = 3 size_flags_vertical = 3
[node name="VBoxContainer" type="VBoxContainer" parent="MailMenu/MarginContainer/Panel/VBoxContainer/Columns/InboxPanel"] [node name="VBoxContainer" type="VBoxContainer" parent="MailMenu/MarginContainer/Panel/VBoxContainer/Columns/InboxPanel"]
@ -295,13 +295,14 @@ text = "Inbox"
horizontal_alignment = 1 horizontal_alignment = 1
[node name="InboxItems" type="ItemList" parent="MailMenu/MarginContainer/Panel/VBoxContainer/Columns/InboxPanel/VBoxContainer"] [node name="InboxItems" type="ItemList" parent="MailMenu/MarginContainer/Panel/VBoxContainer/Columns/InboxPanel/VBoxContainer"]
custom_minimum_size = Vector2(0, 220) custom_minimum_size = Vector2(0, 160)
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3 size_flags_vertical = 3
[node name="SentPanel" type="PanelContainer" parent="MailMenu/MarginContainer/Panel/VBoxContainer/Columns"] [node name="SentPanel" type="PanelContainer" parent="MailMenu/MarginContainer/Panel/VBoxContainer/Columns"]
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 2 size_flags_horizontal = 3
size_flags_vertical = 3 size_flags_vertical = 3
[node name="VBoxContainer" type="VBoxContainer" parent="MailMenu/MarginContainer/Panel/VBoxContainer/Columns/SentPanel"] [node name="VBoxContainer" type="VBoxContainer" parent="MailMenu/MarginContainer/Panel/VBoxContainer/Columns/SentPanel"]
@ -314,8 +315,9 @@ text = "Sent"
horizontal_alignment = 1 horizontal_alignment = 1
[node name="SentItems" type="ItemList" parent="MailMenu/MarginContainer/Panel/VBoxContainer/Columns/SentPanel/VBoxContainer"] [node name="SentItems" type="ItemList" parent="MailMenu/MarginContainer/Panel/VBoxContainer/Columns/SentPanel/VBoxContainer"]
custom_minimum_size = Vector2(0, 220) custom_minimum_size = Vector2(0, 160)
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3 size_flags_vertical = 3
[node name="DetailsComposePanel" type="PanelContainer" parent="MailMenu/MarginContainer/Panel/VBoxContainer"] [node name="DetailsComposePanel" type="PanelContainer" parent="MailMenu/MarginContainer/Panel/VBoxContainer"]
@ -330,7 +332,7 @@ layout_mode = 2
text = "Selected Mail" text = "Selected Mail"
[node name="SelectedMailBody" type="TextEdit" parent="MailMenu/MarginContainer/Panel/VBoxContainer/DetailsComposePanel/VBoxContainer"] [node name="SelectedMailBody" type="TextEdit" parent="MailMenu/MarginContainer/Panel/VBoxContainer/DetailsComposePanel/VBoxContainer"]
custom_minimum_size = Vector2(0, 120) custom_minimum_size = Vector2(0, 80)
layout_mode = 2 layout_mode = 2
editable = false editable = false
@ -343,7 +345,7 @@ layout_mode = 2
placeholder_text = "Subject" placeholder_text = "Subject"
[node name="ComposeBody" type="TextEdit" parent="MailMenu/MarginContainer/Panel/VBoxContainer/DetailsComposePanel/VBoxContainer"] [node name="ComposeBody" type="TextEdit" parent="MailMenu/MarginContainer/Panel/VBoxContainer/DetailsComposePanel/VBoxContainer"]
custom_minimum_size = Vector2(0, 120) custom_minimum_size = Vector2(0, 80)
layout_mode = 2 layout_mode = 2
placeholder_text = "Write your mail here" placeholder_text = "Write your mail here"

View File

@ -0,0 +1,135 @@
using CraftingApi.Models;
using CraftingApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace CraftingApi.Controllers;
[ApiController]
[Route("api/[controller]")]
public class CraftingController : ControllerBase
{
private readonly CraftingStore _crafting;
public CraftingController(CraftingStore crafting)
{
_crafting = crafting;
}
[HttpGet("recipes")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> ListRecipes()
{
var recipes = await _crafting.ListRecipesAsync();
return Ok(recipes.Select(CraftingRecipeResponse.FromModel).ToList());
}
[HttpGet("recipes/{recipeKey}")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> GetRecipe(string recipeKey)
{
var recipe = await _crafting.GetRecipeAsync(recipeKey);
return recipe is null ? NotFound() : Ok(CraftingRecipeResponse.FromModel(recipe));
}
[HttpPost("recipes/{recipeKey}")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> CreateRecipe(string recipeKey, [FromBody] UpsertCraftingRecipeRequest request)
{
var validationError = ValidateRecipeRequest(recipeKey, request);
if (validationError is not null)
return validationError;
var recipe = _crafting.BuildRecipe(recipeKey, request);
var created = await _crafting.CreateRecipeAsync(recipe);
return created ? Ok(CraftingRecipeResponse.FromModel(recipe)) : Conflict("Recipe already exists");
}
[HttpPut("recipes/{recipeKey}")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> UpsertRecipe(string recipeKey, [FromBody] UpsertCraftingRecipeRequest request)
{
var validationError = ValidateRecipeRequest(recipeKey, request);
if (validationError is not null)
return validationError;
var recipe = await _crafting.UpsertRecipeAsync(recipeKey, request);
return Ok(CraftingRecipeResponse.FromModel(recipe));
}
[HttpGet("characters/{characterId}/available-recipes")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> AvailableRecipes(string characterId)
{
var access = await ResolveCharacterAccessAsync(characterId);
if (access is IActionResult errorResult)
return errorResult;
var available = await _crafting.GetAvailableRecipesAsync((CraftingStore.CharacterAccessResult)access!);
return Ok(available);
}
[HttpPost("characters/{characterId}/craft")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Craft(string characterId, [FromBody] CraftRecipeRequest request)
{
if (string.IsNullOrWhiteSpace(request.RecipeKey))
return BadRequest("recipeKey required");
if (request.Quantity <= 0)
return BadRequest("quantity must be greater than 0");
var access = await ResolveCharacterAccessAsync(characterId);
if (access is IActionResult errorResult)
return errorResult;
var result = await _crafting.CraftAsync((CraftingStore.CharacterAccessResult)access!, request);
return result.Status switch
{
CraftingStore.CraftStatus.RecipeNotFound => NotFound("Recipe not found"),
CraftingStore.CraftStatus.MissingStation => Conflict("Required crafting station is not available"),
CraftingStore.CraftStatus.MissingInputs => Conflict(new { missingRequirements = result.MissingRequirements }),
CraftingStore.CraftStatus.InvalidRecipe => BadRequest(new { missingRequirements = result.MissingRequirements }),
_ => Ok(new CraftRecipeResponse
{
CharacterId = characterId,
RecipeKey = request.RecipeKey.Trim().ToLowerInvariant(),
CraftedQuantity = result.CraftedQuantity,
Consumed = result.Consumed,
Produced = result.Produced
})
};
}
private async Task<object> ResolveCharacterAccessAsync(string characterId)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
var access = await _crafting.ResolveCharacterAsync(characterId, userId, User.IsInRole("SUPER"));
if (!access.Exists)
return NotFound();
if (!access.IsAuthorized)
return Forbid();
return access;
}
private IActionResult? ValidateRecipeRequest(string recipeKey, UpsertCraftingRecipeRequest request)
{
if (string.IsNullOrWhiteSpace(recipeKey))
return BadRequest("recipeKey required");
if (string.IsNullOrWhiteSpace(request.Name))
return BadRequest("name required");
if (request.Inputs.Count == 0)
return BadRequest("At least one input is required");
if (request.Outputs.Count == 0)
return BadRequest("At least one output is required");
if (request.Inputs.Any(i => string.IsNullOrWhiteSpace(i.ItemKey) || i.Quantity <= 0))
return BadRequest("All inputs must have itemKey and quantity > 0");
if (request.Outputs.Any(i => string.IsNullOrWhiteSpace(i.ItemKey) || i.Quantity <= 0))
return BadRequest("All outputs must have itemKey and quantity > 0");
return null;
}
}

View File

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" />
<PackageReference Include="MongoDB.Driver" Version="3.4.3" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,10 @@
# CraftingApi
- `GET /api/crafting/recipes`
- `GET /api/crafting/recipes/{recipeKey}`
- `POST /api/crafting/recipes/{recipeKey}` (`SUPER`)
- `PUT /api/crafting/recipes/{recipeKey}` (`SUPER`)
- `GET /api/crafting/characters/{characterId}/available-recipes`
- `POST /api/crafting/characters/{characterId}/craft`
Recipes are data-driven and support `stationType`, `inputs`, `outputs`, and `enabled`.

View File

@ -0,0 +1,10 @@
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 5005
ENTRYPOINT ["dotnet", "CraftingApi.dll"]

View File

@ -0,0 +1,10 @@
namespace CraftingApi.Models;
public class AvailableCraftingRecipeResponse
{
public CraftingRecipeResponse Recipe { get; set; } = new();
public bool CanCraft { get; set; }
public List<string> MissingRequirements { get; set; } = [];
}

View File

@ -0,0 +1,12 @@
namespace CraftingApi.Models;
public class CraftRecipeRequest
{
public string RecipeKey { get; set; } = string.Empty;
public int Quantity { get; set; } = 1;
public string? LocationId { get; set; }
public string? StationObjectId { get; set; }
}

View File

@ -0,0 +1,14 @@
namespace CraftingApi.Models;
public class CraftRecipeResponse
{
public string CharacterId { get; set; } = string.Empty;
public string RecipeKey { get; set; } = string.Empty;
public int CraftedQuantity { get; set; }
public List<CraftingIngredient> Consumed { get; set; } = [];
public List<CraftingIngredient> Produced { get; set; } = [];
}

View File

@ -0,0 +1,8 @@
namespace CraftingApi.Models;
public class CraftingIngredient
{
public string ItemKey { get; set; } = string.Empty;
public int Quantity { get; set; } = 1;
}

View File

@ -0,0 +1,38 @@
using MongoDB.Bson.Serialization.Attributes;
namespace CraftingApi.Models;
[BsonIgnoreExtraElements]
public class CraftingRecipe
{
[BsonId]
[BsonElement("recipeKey")]
public string RecipeKey { get; set; } = string.Empty;
[BsonElement("name")]
public string Name { get; set; } = string.Empty;
[BsonElement("category")]
public string Category { get; set; } = "misc";
[BsonElement("stationType")]
public string StationType { get; set; } = "hand";
[BsonElement("inputs")]
public List<CraftingIngredient> Inputs { get; set; } = [];
[BsonElement("outputs")]
public List<CraftingIngredient> Outputs { get; set; } = [];
[BsonElement("craftTimeSeconds")]
public int CraftTimeSeconds { get; set; }
[BsonElement("enabled")]
public bool Enabled { get; set; } = true;
[BsonElement("createdUtc")]
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
[BsonElement("updatedUtc")]
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}

View File

@ -0,0 +1,32 @@
namespace CraftingApi.Models;
public class CraftingRecipeResponse
{
public string RecipeKey { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public string StationType { get; set; } = string.Empty;
public List<CraftingIngredient> Inputs { get; set; } = [];
public List<CraftingIngredient> Outputs { get; set; } = [];
public int CraftTimeSeconds { get; set; }
public bool Enabled { get; set; }
public static CraftingRecipeResponse FromModel(CraftingRecipe recipe) => new()
{
RecipeKey = recipe.RecipeKey,
Name = recipe.Name,
Category = recipe.Category,
StationType = recipe.StationType,
Inputs = recipe.Inputs.Select(i => new CraftingIngredient { ItemKey = i.ItemKey, Quantity = i.Quantity }).ToList(),
Outputs = recipe.Outputs.Select(i => new CraftingIngredient { ItemKey = i.ItemKey, Quantity = i.Quantity }).ToList(),
CraftTimeSeconds = recipe.CraftTimeSeconds,
Enabled = recipe.Enabled
};
}

View File

@ -0,0 +1,18 @@
namespace CraftingApi.Models;
public class UpsertCraftingRecipeRequest
{
public string Name { get; set; } = string.Empty;
public string Category { get; set; } = "misc";
public string StationType { get; set; } = "hand";
public List<CraftingIngredient> Inputs { get; set; } = [];
public List<CraftingIngredient> Outputs { get; set; } = [];
public int CraftTimeSeconds { get; set; }
public bool Enabled { get; set; } = true;
}

View File

@ -0,0 +1,105 @@
using CraftingApi.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddSingleton<CraftingStore>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Crafting API", Version = "v1" });
c.AddSecurityDefinition("bearerAuth", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
Description = "Paste your access token here (no 'Bearer ' prefix needed)."
});
c.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{ Type = ReferenceType.SecurityScheme, Id = "bearerAuth" }
},
Array.Empty<string>()
}
});
});
var cfg = builder.Configuration;
var jwtKey = cfg["Jwt:Key"] ?? throw new Exception("Jwt:Key missing");
var issuer = cfg["Jwt:Issuer"] ?? "promiscuity";
var aud = cfg["Jwt:Audience"] ?? issuer;
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o =>
{
o.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = issuer,
ValidateAudience = true,
ValidAudience = aud,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30)
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
var feature = context.Features.Get<IExceptionHandlerFeature>();
var exception = feature?.Error;
var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("GlobalException");
var traceId = context.TraceIdentifier;
if (exception is not null)
{
logger.LogError(
exception,
"Unhandled exception for {Method} {Path}. TraceId={TraceId}",
context.Request.Method,
context.Request.Path,
traceId
);
}
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(new
{
type = "https://httpstatuses.com/500",
title = "Internal Server Error",
status = 500,
detail = exception?.Message ?? "An unexpected server error occurred.",
traceId
});
});
});
app.MapGet("/healthz", () => Results.Ok("ok"));
app.UseSwagger();
app.UseSwaggerUI(o =>
{
o.SwaggerEndpoint("/swagger/v1/swagger.json", "Crafting API v1");
o.RoutePrefix = "swagger";
});
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();

View File

@ -0,0 +1,3 @@
# CraftingApi
Data-driven crafting recipes and instant crafting execution.

View File

@ -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;
}
}

View File

@ -0,0 +1,7 @@
{
"Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5005" } } },
"MongoDB": { "ConnectionString": "mongodb://127.0.0.1:27017", "DatabaseName": "promiscuity" },
"Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" },
"Logging": { "LogLevel": { "Default": "Information" } },
"AllowedHosts": "*"
}

View File

@ -0,0 +1,7 @@
{
"Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5005" } } },
"MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" },
"Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" },
"Logging": { "LogLevel": { "Default": "Information" } },
"AllowedHosts": "*"
}

View File

@ -0,0 +1,28 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: promiscuity-crafting
labels:
app: promiscuity-crafting
spec:
replicas: 2
selector:
matchLabels:
app: promiscuity-crafting
template:
metadata:
labels:
app: promiscuity-crafting
spec:
containers:
- name: promiscuity-crafting
image: promiscuity-crafting:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 5005
readinessProbe:
httpGet:
path: /healthz
port: 5005
initialDelaySeconds: 5
periodSeconds: 10

View File

@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: promiscuity-crafting
labels:
app: promiscuity-crafting
spec:
selector:
app: promiscuity-crafting
type: NodePort
ports:
- name: http
port: 80
targetPort: 5005
nodePort: 30085

View File

@ -12,6 +12,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InventoryApi", "InventoryAp
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MailApi", "MailApi\MailApi.csproj", "{2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MailApi", "MailApi\MailApi.csproj", "{2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CraftingApi", "CraftingApi\CraftingApi.csproj", "{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -38,6 +40,10 @@ Global
{2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Debug|Any CPU.Build.0 = Debug|Any CPU {2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Release|Any CPU.ActiveCfg = Release|Any CPU {2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Release|Any CPU.Build.0 = Release|Any CPU {2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Release|Any CPU.Build.0 = Release|Any CPU
{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE