From 0f44a3482cff44b4ff9eabbf19878dc7c6424415 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Wed, 27 May 2026 23:57:12 -0400 Subject: [PATCH] fix(shops): smoke-test fallout from today's shop-cluster ship MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues caught in a real-client smoke run against the freshly bootstrapped DB: 1. NRE in ShadowverseTranslationMiddleware for parameterless actions. Five new actions (Sleeve.Info, LeaderSkin.{Ids,Products}, ItemPurchase.Info, SpotCardExchange.Top) took no parameters, but the middleware does `endpointDescriptor.Parameters.FirstOrDefault().ParameterType` to discover the request DTO — `FirstOrDefault` returns null on a zero-param action and `.ParameterType` NREs. Tests didn't catch it because the test client POSTs plain JSON, bypassing this path. Fix: each action now takes `BaseRequest _` matching the codebase convention (PuzzleController.Info, BattlePassController.Info, etc.), plus the middleware throws an actionable InvalidOperationException pointing at the convention so the next contributor doesn't repeat the mistake. 2. Leader-skin set sale showed up as "FREE / Claim" with empty Includes panel after the viewer bought every skin in a series with no configured bonus items. Root cause: ComputeRewardStatus emitted status=1 (not_got) when set_sales_status != 0 regardless of whether rewards.items was empty, and SkinPurchaseInfoTask. CreateSetSaleInfo flags `is_free=true` on (is_completed && not_got). Prod ships status=0 when items is empty even with set_sales_status==1 — we now mirror that. 504 tests still pass. Co-Authored-By: Claude Opus 4.7 --- .../Controllers/ItemPurchaseController.cs | 3 ++- .../Controllers/LeaderSkinController.cs | 20 ++++++++++++++----- .../Controllers/SleeveController.cs | 3 ++- .../Controllers/SpotCardExchangeController.cs | 3 ++- .../ShadowverseTranslationMiddleware.cs | 14 ++++++++++++- 5 files changed, 34 insertions(+), 9 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/ItemPurchaseController.cs b/SVSim.EmulatedEntrypoint/Controllers/ItemPurchaseController.cs index 7bd08a8..3510c5c 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/ItemPurchaseController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/ItemPurchaseController.cs @@ -5,6 +5,7 @@ using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Services; using SVSim.EmulatedEntrypoint.Models.Dtos; +using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ItemPurchase; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ItemPurchase; using SVSim.EmulatedEntrypoint.Services; @@ -31,7 +32,7 @@ public class ItemPurchaseController : SVSimController } [HttpPost("info")] - public async Task> Info() + public async Task> Info(BaseRequest _) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); diff --git a/SVSim.EmulatedEntrypoint/Controllers/LeaderSkinController.cs b/SVSim.EmulatedEntrypoint/Controllers/LeaderSkinController.cs index 927db7f..a85850a 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/LeaderSkinController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/LeaderSkinController.cs @@ -5,6 +5,7 @@ using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Services; using SVSim.EmulatedEntrypoint.Models.Dtos; +using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.LeaderSkin; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.LeaderSkin; @@ -76,7 +77,7 @@ public class LeaderSkinController : SVSimController } [HttpPost("ids")] - public async Task> Ids() + public async Task> Ids(BaseRequest _) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); @@ -90,7 +91,7 @@ public class LeaderSkinController : SVSimController } [HttpPost("products")] - public async Task>> Products() + public async Task>> Products(BaseRequest _) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); @@ -262,15 +263,24 @@ public class LeaderSkinController : SVSimController /// /// Computes the per-viewer rewards.status for a series: - /// 0=none — set_sales_status==0 (no set sale active) - /// 1=not_got — series completed by viewer but bonus unclaimed + /// 0=none — set_sales_status==0 OR no bonus items configured (matches prod, which ships + /// status=0 for series where items[] is empty even when set_sales_status==1) + /// 1=not_got — bonus exists, series completed by viewer, bonus unclaimed /// 2=got — viewer claimed the bonus - /// 1 (effectively "available later") when set sale active but viewer hasn't completed it. + /// 1 (effectively "available later") when set sale active with bonus and viewer hasn't + /// completed the series. /// The 1/2 distinction matches the client enum (RewardStatus.not_got vs .got). + /// + /// Important: emitting status=1 when items[] is empty triggers the client's + /// is_completed && not_got branch in SkinPurchaseInfoTask.CreateSetSaleInfo, + /// which marks the set sale as FREE and renders a useless "claim" button for a + /// nonexistent bonus. Always return 0 when there's nothing to claim. + /// /// private static int ComputeRewardStatus(LeaderSkinShopSeriesEntry series, bool seriesCompleted, bool claimed) { if (series.SetSalesStatus == 0) return 0; + if (series.SetCompletionRewards.Count == 0) return 0; if (claimed) return 2; if (seriesCompleted) return 1; return 1; diff --git a/SVSim.EmulatedEntrypoint/Controllers/SleeveController.cs b/SVSim.EmulatedEntrypoint/Controllers/SleeveController.cs index 0da5b50..c9d623e 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/SleeveController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/SleeveController.cs @@ -5,6 +5,7 @@ using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Services; using SVSim.EmulatedEntrypoint.Models.Dtos; +using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Sleeve; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Sleeve; @@ -27,7 +28,7 @@ public class SleeveController : SVSimController } [HttpPost("info")] - public async Task> Info() + public async Task> Info(BaseRequest _) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); diff --git a/SVSim.EmulatedEntrypoint/Controllers/SpotCardExchangeController.cs b/SVSim.EmulatedEntrypoint/Controllers/SpotCardExchangeController.cs index ff85f78..584cbc7 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/SpotCardExchangeController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/SpotCardExchangeController.cs @@ -5,6 +5,7 @@ using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Services; using SVSim.EmulatedEntrypoint.Models.Dtos; +using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.SpotCardExchange; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.SpotCardExchange; @@ -38,7 +39,7 @@ public class SpotCardExchangeController : SVSimController } [HttpPost("top")] - public async Task> Top() + public async Task> Top(BaseRequest _) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); diff --git a/SVSim.EmulatedEntrypoint/Middlewares/ShadowverseTranslationMiddleware.cs b/SVSim.EmulatedEntrypoint/Middlewares/ShadowverseTranslationMiddleware.cs index 2ffc0e6..b92cd4e 100644 --- a/SVSim.EmulatedEntrypoint/Middlewares/ShadowverseTranslationMiddleware.cs +++ b/SVSim.EmulatedEntrypoint/Middlewares/ShadowverseTranslationMiddleware.cs @@ -100,7 +100,19 @@ public class ShadowverseTranslationMiddleware : IMiddleware throw; } - Type requestType = endpointDescriptor.Parameters.FirstOrDefault().ParameterType; + var firstParam = endpointDescriptor.Parameters.FirstOrDefault(); + if (firstParam is null) + { + // Action method has no parameters — middleware can't bind the (encrypted+msgpacked) + // body to anything. The codebase convention is to take a BaseRequest even for body- + // less endpoints (see e.g. PuzzleController.Info(BaseRequest _)). Fail loud with a + // specific message rather than NREing below on .ParameterType. + throw new InvalidOperationException( + $"Action {endpointDescriptor.DisplayName} has no parameters; the SV translation " + + "middleware needs at least one to bind the decrypted body. Add a BaseRequest parameter " + + "(or a derived DTO) — see other *Info/*Top actions for the convention."); + } + Type requestType = firstParam.ParameterType; object? data; try {