From 61ae0863328b2cb709a87e329588bc4455141417 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 30 May 2026 23:30:18 -0400 Subject: [PATCH] fix(gacha-points): look up by odds_gacha_id, not parent_gacha_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two wire fields differ for seasonal packs (verified against traffic_prod_all_gacha_exchange.ndjson — every captured request pairs odds_gacha_id=16xxx with parent_gacha_id=10xxx). The OLD DTO docstring assumed they were always equal; today's controller used ParentGachaId, which lands on the base/family pack id (often a synthesized disabled stub with no GachaPointConfig) and returns []. Fix: - GetGachaPointRewards and ExchangeGachaPoint now consume OddsGachaId. - Update both DTO docstrings to document the seasonal-pack pattern. - Regression test seeds (16015 enabled w/ GachaPointConfig, 10015 disabled stub w/o config) and asserts the response uses 16015's catalog. Symptom: opening pack 16015 (parent_gacha_id=16015 in /pack/open) accrued gacha points correctly, but /pack/get_gacha_point_rewards with {odds_gacha_id:16015, parent_gacha_id:10015} returned an empty list. Co-Authored-By: Claude Opus 4.7 --- .../Controllers/PackController.cs | 10 ++- .../Pack/ExchangeGachaPointRequest.cs | 5 ++ .../Pack/GetGachaPointRewardsRequest.cs | 10 ++- .../PackControllerGachaPointTests.cs | 61 +++++++++++++++++++ 4 files changed, 82 insertions(+), 4 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs index fa1ddfb..8a588af 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs @@ -189,7 +189,11 @@ public class PackController : SVSimController { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); - var rewards = await _gachaPoint.GetRewardsAsync(request.ParentGachaId, viewerId); + // odds_gacha_id is the active seasonal pack id (the one with GachaPointConfig + + // balance). parent_gacha_id is the base_pack_id of the family — not the lookup key. + // See GetGachaPointRewardsRequest docstring; verified against + // traffic_prod_all_gacha_exchange.ndjson. + var rewards = await _gachaPoint.GetRewardsAsync(request.OddsGachaId, viewerId); return new GetGachaPointRewardsResponse { @@ -217,7 +221,9 @@ public class PackController : SVSimController .AsSplitQuery() .FirstAsync(v => v.Id == viewerId); - var outcome = await _gachaPoint.TryExchangeAsync(viewer, request.ParentGachaId, request.CardId); + // Use odds_gacha_id (the seasonal pack id) — that's where the balance / received marker + // live. Mirrors the GetGachaPointRewards fix. + var outcome = await _gachaPoint.TryExchangeAsync(viewer, request.OddsGachaId, request.CardId); if (!outcome.Success) return BadRequest(new { error = outcome.Error }); await _db.SaveChangesAsync(); diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Pack/ExchangeGachaPointRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Pack/ExchangeGachaPointRequest.cs index f554b84..0e1d796 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Pack/ExchangeGachaPointRequest.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Pack/ExchangeGachaPointRequest.cs @@ -4,6 +4,11 @@ using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Pack; +/// +/// Inbound /pack/exchange_gacha_point body. See +/// for the odds_gacha_id vs parent_gacha_id split — +/// same pattern here: the server consumes odds_gacha_id for the lookup. +/// [MessagePackObject] public class ExchangeGachaPointRequest : BaseRequest { diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Pack/GetGachaPointRewardsRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Pack/GetGachaPointRewardsRequest.cs index 47bb4b9..c5bd2fb 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Pack/GetGachaPointRewardsRequest.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Pack/GetGachaPointRewardsRequest.cs @@ -5,8 +5,14 @@ using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Pack; /// -/// Inbound /pack/get_gacha_point_rewards body. Capture shows odds_gacha_id and parent_gacha_id -/// are always the same value (the pack id); we only consume parent_gacha_id for the lookup. +/// Inbound /pack/get_gacha_point_rewards body. +/// +/// The two ids DIFFER for seasonal packs (e.g. UCL): +/// odds_gacha_id = the seasonal "current" pack id (matches /pack/info parent_gacha_id), +/// and is where the GachaPointConfig + gacha-point balance live. +/// parent_gacha_id = the base/family pack id (matches /pack/info base_pack_id). +/// Verified against traffic_prod_all_gacha_exchange.ndjson — every captured request shows +/// the pair as (16xxx, 10xxx). Server consumes odds_gacha_id for the lookup. /// [MessagePackObject] public class GetGachaPointRewardsRequest : BaseRequest diff --git a/SVSim.UnitTests/Controllers/PackControllerGachaPointTests.cs b/SVSim.UnitTests/Controllers/PackControllerGachaPointTests.cs index f1cb1d1..6b28bc5 100644 --- a/SVSim.UnitTests/Controllers/PackControllerGachaPointTests.cs +++ b/SVSim.UnitTests/Controllers/PackControllerGachaPointTests.cs @@ -68,6 +68,67 @@ public class PackControllerGachaPointTests Assert.That(rewardList[0].GetProperty("reward_number").GetInt32(), Is.EqualTo(1)); } + [Test] + public async Task GetGachaPointRewards_uses_odds_gacha_id_when_it_differs_from_parent_gacha_id() + { + // Regression for the seasonal-pack case captured in traffic_prod_all_gacha_exchange.ndjson: + // the client sends {odds_gacha_id: 16xxx, parent_gacha_id: 10xxx} where odds_gacha_id is + // the active seasonal pack (carries GachaPointConfig + balance) and parent_gacha_id is + // the base/family pack id (often a disabled stub in our DB). Looking up by parent_gacha_id + // would land on the stub and return []. + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Classes.Add(new ClassEntry { Id = 0, Name = "Neutral" }); + var set = new ShadowverseCardSetEntry { Id = 16015, IsInRotation = true }; + db.CardSets.Add(set); + set.Cards.Add(new ShadowverseCardEntry + { + Id = 115041010, Name = "ucl-leg", Rarity = Rarity.Legendary, + Class = db.Classes.Local.First(), IsFoil = false, + }); + db.CardCosmeticRewards.Add(new CardCosmeticReward + { + CardId = 115041010, Type = CosmeticType.Emblem, CosmeticId = 1150410100, + }); + // Active seasonal pack — has GachaPointConfig. + db.Packs.Add(new PackConfigEntry + { + Id = 16015, BasePackId = 10015, PackCategory = PackCategory.None, + CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30), + GachaType = 1, GachaDetail = "UCL season", + GachaPointConfig = new PackGachaPointConfig { ExchangeablePoint = 400, IncreaseGachaPoint = 1 }, + }); + // Disabled base/family stub — no GachaPointConfig (matches the synthesized-stub state). + db.Packs.Add(new PackConfigEntry + { + Id = 10015, BasePackId = 10015, PackCategory = PackCategory.None, + CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30), + GachaType = 1, GachaDetail = "UCL family stub", IsEnabled = false, + GachaPointConfig = null, + }); + await db.SaveChangesAsync(); + } + await factory.SeedPackDrawTableFromSetAsync(16015, 16015); + + using var client = factory.CreateAuthenticatedClient(viewerId); + // Mirrors the prod capture shape — odds_gacha_id is the active pack, parent_gacha_id + // is the base/family id (here, a disabled stub). + var body = JsonBody("""{"odds_gacha_id":16015,"parent_gacha_id":10015,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""); + var response = await client.PostAsync("/pack/get_gacha_point_rewards", body); + + var text = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), text); + + using var doc = JsonDocument.Parse(text); + var rewards = doc.RootElement.GetProperty("gacha_point_rewards"); + Assert.That(rewards.GetArrayLength(), Is.EqualTo(1), + "lookup must resolve via odds_gacha_id (16015), not parent_gacha_id (10015)"); + Assert.That(rewards[0].GetProperty("card_id").GetInt64(), Is.EqualTo(115041010)); + } + [Test] public async Task GetGachaPointRewards_wire_keys_match_prod_capture() {