From a283969e4c9082b6736fe860e4adecb1e6d9d1db Mon Sep 17 00:00:00 2001 From: Zeeshaun Date: Thu, 26 Mar 2026 09:36:42 -0500 Subject: [PATCH] Mailbox support + crafting api --- .gitea/workflows/deploy-crafting.yml | 78 +++ game/scenes/Levels/location_level.gd | 18 +- game/scenes/Levels/location_level.tscn | 18 +- .../Controllers/CraftingController.cs | 135 ++++++ microservices/CraftingApi/CraftingApi.csproj | 16 + microservices/CraftingApi/DOCUMENTS.md | 10 + microservices/CraftingApi/Dockerfile | 10 + .../Models/AvailableCraftingRecipeResponse.cs | 10 + .../CraftingApi/Models/CraftRecipeRequest.cs | 12 + .../CraftingApi/Models/CraftRecipeResponse.cs | 14 + .../CraftingApi/Models/CraftingIngredient.cs | 8 + .../CraftingApi/Models/CraftingRecipe.cs | 38 ++ .../Models/CraftingRecipeResponse.cs | 32 ++ .../Models/UpsertCraftingRecipeRequest.cs | 18 + microservices/CraftingApi/Program.cs | 105 ++++ microservices/CraftingApi/README.md | 3 + .../CraftingApi/Services/CraftingStore.cs | 455 ++++++++++++++++++ .../CraftingApi/appsettings.Development.json | 7 + microservices/CraftingApi/appsettings.json | 7 + microservices/CraftingApi/k8s/deployment.yaml | 28 ++ microservices/CraftingApi/k8s/service.yaml | 15 + microservices/micro-services.sln | 6 + 22 files changed, 1026 insertions(+), 17 deletions(-) create mode 100644 .gitea/workflows/deploy-crafting.yml create mode 100644 microservices/CraftingApi/Controllers/CraftingController.cs create mode 100644 microservices/CraftingApi/CraftingApi.csproj create mode 100644 microservices/CraftingApi/DOCUMENTS.md create mode 100644 microservices/CraftingApi/Dockerfile create mode 100644 microservices/CraftingApi/Models/AvailableCraftingRecipeResponse.cs create mode 100644 microservices/CraftingApi/Models/CraftRecipeRequest.cs create mode 100644 microservices/CraftingApi/Models/CraftRecipeResponse.cs create mode 100644 microservices/CraftingApi/Models/CraftingIngredient.cs create mode 100644 microservices/CraftingApi/Models/CraftingRecipe.cs create mode 100644 microservices/CraftingApi/Models/CraftingRecipeResponse.cs create mode 100644 microservices/CraftingApi/Models/UpsertCraftingRecipeRequest.cs create mode 100644 microservices/CraftingApi/Program.cs create mode 100644 microservices/CraftingApi/README.md create mode 100644 microservices/CraftingApi/Services/CraftingStore.cs create mode 100644 microservices/CraftingApi/appsettings.Development.json create mode 100644 microservices/CraftingApi/appsettings.json create mode 100644 microservices/CraftingApi/k8s/deployment.yaml create mode 100644 microservices/CraftingApi/k8s/service.yaml diff --git a/.gitea/workflows/deploy-crafting.yml b/.gitea/workflows/deploy-crafting.yml new file mode 100644 index 0000000..eb91676 --- /dev/null +++ b/.gitea/workflows/deploy-crafting.yml @@ -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 diff --git a/game/scenes/Levels/location_level.gd b/game/scenes/Levels/location_level.gd index e8c0a8b..be144f0 100644 --- a/game/scenes/Levels/location_level.gd +++ b/game/scenes/Levels/location_level.gd @@ -1032,15 +1032,15 @@ func _parse_mail_messages(value: Variant) -> Array: continue var message := entry as Dictionary messages.append({ - "id": String(message.get("id", "")).strip_edges(), - "senderCharacterId": String(message.get("senderCharacterId", "")).strip_edges(), - "senderCharacterName": String(message.get("senderCharacterName", "")).strip_edges(), - "recipientCharacterId": String(message.get("recipientCharacterId", "")).strip_edges(), - "recipientCharacterName": String(message.get("recipientCharacterName", "")).strip_edges(), - "subject": String(message.get("subject", "")).strip_edges(), - "body": String(message.get("body", "")).strip_edges(), - "createdUtc": String(message.get("createdUtc", "")).strip_edges(), - "readUtc": String(message.get("readUtc", "")).strip_edges() + "id": str(message.get("id", "")).strip_edges(), + "senderCharacterId": str(message.get("senderCharacterId", "")).strip_edges(), + "senderCharacterName": str(message.get("senderCharacterName", "")).strip_edges(), + "recipientCharacterId": str(message.get("recipientCharacterId", "")).strip_edges(), + "recipientCharacterName": str(message.get("recipientCharacterName", "")).strip_edges(), + "subject": str(message.get("subject", "")).strip_edges(), + "body": str(message.get("body", "")).strip_edges(), + "createdUtc": str(message.get("createdUtc", "")).strip_edges(), + "readUtc": str(message.get("readUtc", "")).strip_edges() }) return messages diff --git a/game/scenes/Levels/location_level.tscn b/game/scenes/Levels/location_level.tscn index 9f563c3..c7b04ec 100644 --- a/game/scenes/Levels/location_level.tscn +++ b/game/scenes/Levels/location_level.tscn @@ -99,9 +99,9 @@ anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 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_bottom = 48 +theme_override_constants/margin_bottom = 24 [node name="Panel" type="PanelContainer" parent="InventoryMenu/MarginContainer"] layout_mode = 2 @@ -282,7 +282,7 @@ theme_override_constants/separation = 12 [node name="InboxPanel" type="PanelContainer" parent="MailMenu/MarginContainer/Panel/VBoxContainer/Columns"] layout_mode = 2 -size_flags_horizontal = 2 +size_flags_horizontal = 3 size_flags_vertical = 3 [node name="VBoxContainer" type="VBoxContainer" parent="MailMenu/MarginContainer/Panel/VBoxContainer/Columns/InboxPanel"] @@ -295,13 +295,14 @@ text = "Inbox" horizontal_alignment = 1 [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 +size_flags_horizontal = 3 size_flags_vertical = 3 [node name="SentPanel" type="PanelContainer" parent="MailMenu/MarginContainer/Panel/VBoxContainer/Columns"] layout_mode = 2 -size_flags_horizontal = 2 +size_flags_horizontal = 3 size_flags_vertical = 3 [node name="VBoxContainer" type="VBoxContainer" parent="MailMenu/MarginContainer/Panel/VBoxContainer/Columns/SentPanel"] @@ -314,8 +315,9 @@ text = "Sent" horizontal_alignment = 1 [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 +size_flags_horizontal = 3 size_flags_vertical = 3 [node name="DetailsComposePanel" type="PanelContainer" parent="MailMenu/MarginContainer/Panel/VBoxContainer"] @@ -330,7 +332,7 @@ layout_mode = 2 text = "Selected Mail" [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 editable = false @@ -343,7 +345,7 @@ layout_mode = 2 placeholder_text = "Subject" [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 placeholder_text = "Write your mail here" diff --git a/microservices/CraftingApi/Controllers/CraftingController.cs b/microservices/CraftingApi/Controllers/CraftingController.cs new file mode 100644 index 0000000..a30a82f --- /dev/null +++ b/microservices/CraftingApi/Controllers/CraftingController.cs @@ -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 ListRecipes() + { + var recipes = await _crafting.ListRecipesAsync(); + return Ok(recipes.Select(CraftingRecipeResponse.FromModel).ToList()); + } + + [HttpGet("recipes/{recipeKey}")] + [Authorize(Roles = "USER,SUPER")] + public async Task 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 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 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 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 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 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; + } +} diff --git a/microservices/CraftingApi/CraftingApi.csproj b/microservices/CraftingApi/CraftingApi.csproj new file mode 100644 index 0000000..0cb0950 --- /dev/null +++ b/microservices/CraftingApi/CraftingApi.csproj @@ -0,0 +1,16 @@ + + + + net8.0 + enable + enable + + + + + + + + + + diff --git a/microservices/CraftingApi/DOCUMENTS.md b/microservices/CraftingApi/DOCUMENTS.md new file mode 100644 index 0000000..eb842fe --- /dev/null +++ b/microservices/CraftingApi/DOCUMENTS.md @@ -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`. diff --git a/microservices/CraftingApi/Dockerfile b/microservices/CraftingApi/Dockerfile new file mode 100644 index 0000000..29a0696 --- /dev/null +++ b/microservices/CraftingApi/Dockerfile @@ -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"] diff --git a/microservices/CraftingApi/Models/AvailableCraftingRecipeResponse.cs b/microservices/CraftingApi/Models/AvailableCraftingRecipeResponse.cs new file mode 100644 index 0000000..bd31204 --- /dev/null +++ b/microservices/CraftingApi/Models/AvailableCraftingRecipeResponse.cs @@ -0,0 +1,10 @@ +namespace CraftingApi.Models; + +public class AvailableCraftingRecipeResponse +{ + public CraftingRecipeResponse Recipe { get; set; } = new(); + + public bool CanCraft { get; set; } + + public List MissingRequirements { get; set; } = []; +} diff --git a/microservices/CraftingApi/Models/CraftRecipeRequest.cs b/microservices/CraftingApi/Models/CraftRecipeRequest.cs new file mode 100644 index 0000000..64493cd --- /dev/null +++ b/microservices/CraftingApi/Models/CraftRecipeRequest.cs @@ -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; } +} diff --git a/microservices/CraftingApi/Models/CraftRecipeResponse.cs b/microservices/CraftingApi/Models/CraftRecipeResponse.cs new file mode 100644 index 0000000..2f3d78c --- /dev/null +++ b/microservices/CraftingApi/Models/CraftRecipeResponse.cs @@ -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 Consumed { get; set; } = []; + + public List Produced { get; set; } = []; +} diff --git a/microservices/CraftingApi/Models/CraftingIngredient.cs b/microservices/CraftingApi/Models/CraftingIngredient.cs new file mode 100644 index 0000000..476b509 --- /dev/null +++ b/microservices/CraftingApi/Models/CraftingIngredient.cs @@ -0,0 +1,8 @@ +namespace CraftingApi.Models; + +public class CraftingIngredient +{ + public string ItemKey { get; set; } = string.Empty; + + public int Quantity { get; set; } = 1; +} diff --git a/microservices/CraftingApi/Models/CraftingRecipe.cs b/microservices/CraftingApi/Models/CraftingRecipe.cs new file mode 100644 index 0000000..9295602 --- /dev/null +++ b/microservices/CraftingApi/Models/CraftingRecipe.cs @@ -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 Inputs { get; set; } = []; + + [BsonElement("outputs")] + public List 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; +} diff --git a/microservices/CraftingApi/Models/CraftingRecipeResponse.cs b/microservices/CraftingApi/Models/CraftingRecipeResponse.cs new file mode 100644 index 0000000..bb801a9 --- /dev/null +++ b/microservices/CraftingApi/Models/CraftingRecipeResponse.cs @@ -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 Inputs { get; set; } = []; + + public List 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 + }; +} diff --git a/microservices/CraftingApi/Models/UpsertCraftingRecipeRequest.cs b/microservices/CraftingApi/Models/UpsertCraftingRecipeRequest.cs new file mode 100644 index 0000000..3cdbdba --- /dev/null +++ b/microservices/CraftingApi/Models/UpsertCraftingRecipeRequest.cs @@ -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 Inputs { get; set; } = []; + + public List Outputs { get; set; } = []; + + public int CraftTimeSeconds { get; set; } + + public bool Enabled { get; set; } = true; +} diff --git a/microservices/CraftingApi/Program.cs b/microservices/CraftingApi/Program.cs new file mode 100644 index 0000000..a73448b --- /dev/null +++ b/microservices/CraftingApi/Program.cs @@ -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(); + +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() + } + }); +}); + +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(); + var exception = feature?.Error; + var logger = context.RequestServices.GetRequiredService().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(); diff --git a/microservices/CraftingApi/README.md b/microservices/CraftingApi/README.md new file mode 100644 index 0000000..d57d76a --- /dev/null +++ b/microservices/CraftingApi/README.md @@ -0,0 +1,3 @@ +# CraftingApi + +Data-driven crafting recipes and instant crafting execution. diff --git a/microservices/CraftingApi/Services/CraftingStore.cs b/microservices/CraftingApi/Services/CraftingStore.cs new file mode 100644 index 0000000..17095a8 --- /dev/null +++ b/microservices/CraftingApi/Services/CraftingStore.cs @@ -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 _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; + } +} diff --git a/microservices/CraftingApi/appsettings.Development.json b/microservices/CraftingApi/appsettings.Development.json new file mode 100644 index 0000000..5011e7f --- /dev/null +++ b/microservices/CraftingApi/appsettings.Development.json @@ -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": "*" +} diff --git a/microservices/CraftingApi/appsettings.json b/microservices/CraftingApi/appsettings.json new file mode 100644 index 0000000..f788448 --- /dev/null +++ b/microservices/CraftingApi/appsettings.json @@ -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": "*" +} diff --git a/microservices/CraftingApi/k8s/deployment.yaml b/microservices/CraftingApi/k8s/deployment.yaml new file mode 100644 index 0000000..5d92180 --- /dev/null +++ b/microservices/CraftingApi/k8s/deployment.yaml @@ -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 diff --git a/microservices/CraftingApi/k8s/service.yaml b/microservices/CraftingApi/k8s/service.yaml new file mode 100644 index 0000000..5454d4c --- /dev/null +++ b/microservices/CraftingApi/k8s/service.yaml @@ -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 diff --git a/microservices/micro-services.sln b/microservices/micro-services.sln index ae20ffd..fc911e1 100644 --- a/microservices/micro-services.sln +++ b/microservices/micro-services.sln @@ -12,6 +12,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InventoryApi", "InventoryAp EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MailApi", "MailApi\MailApi.csproj", "{2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CraftingApi", "CraftingApi\CraftingApi.csproj", "{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution 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}.Release|Any CPU.ActiveCfg = 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 GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE