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()
{