From 6c7e8ae8ad49694b58c8b455a3220afea96f567d Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 8 Jun 2026 21:38:26 -0400 Subject: [PATCH] feat(pack): /pack/info filters spent free-pack children and emits campaign metadata --- .../Controllers/PackController.cs | 42 ++++++++- .../Controllers/PackControllerInfoTests.cs | 89 +++++++++++++++++++ 2 files changed, 127 insertions(+), 4 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs index 4644c50..5a3b077 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs @@ -92,10 +92,19 @@ public class PackController : SVSimController .Select(b => new { b.PackId, b.Points }) .ToDictionaryAsync(x => x.PackId, x => x.Points); + // Per-viewer free-pack claim records, keyed by campaign id. Drives the + // "drop the type_detail=10 child once today's quota is spent" filter in ToDto. + // Plain projection — no owned-entity tracking needed (mirrors the items query above). + var freeClaimsByCampaignId = await _db.Viewers + .Where(v => v.Id == viewerId) + .SelectMany(v => v.FreePackClaims) + .Select(c => new { c.FreeGachaCampaignId, c.LastClaimedAt, c.ClaimCount }) + .ToDictionaryAsync(x => x.FreeGachaCampaignId, x => (x.LastClaimedAt, x.ClaimCount)); + return new PackInfoResponse { PackConfigList = packs - .Select(p => ToDto(p, openCounts, ownedItemsByItemId, gachaPointBalancesByPackId)) + .Select(p => ToDto(p, openCounts, ownedItemsByItemId, gachaPointBalancesByPackId, freeClaimsByCampaignId)) .ToList(), }; } @@ -104,14 +113,31 @@ public class PackController : SVSimController PackConfigEntry p, IReadOnlyDictionary openCounts, IReadOnlyDictionary ownedItemsByItemId, - IReadOnlyDictionary gachaPointBalancesByPackId) + IReadOnlyDictionary gachaPointBalancesByPackId, + IReadOnlyDictionary freeClaimsByCampaignId) { int openCount = openCounts.TryGetValue(p.Id, out var oc) ? oc.OpenCount : 0; + // Drop type_detail=10 (FREE_PACKS) children whose daily quota for THIS viewer is spent. + // Mirrors prod behavior: post-claim /pack/info simply omits the free child from + // child_gacha_info (verified in traffic_event_crate_free_pack.ndjson lines 28→32). + // Today's claim count >= DailyFreeGachaCount and same UTC date => hide. + var today = DateTime.UtcNow.Date; + bool ChildAvailable(PackChildGachaEntry c) + { + if (c.TypeDetail != 10) return true; + if (c.FreeGachaCampaignId is not int campaignId) return true; + if (!freeClaimsByCampaignId.TryGetValue(campaignId, out var claim)) return true; + if (claim.LastClaimedAt.Date != today) return true; + int dailyCap = c.DailyFreeGachaCount > 0 ? c.DailyFreeGachaCount : 1; + return claim.ClaimCount < dailyCap; + } + var visibleChildren = p.ChildGachas.Where(ChildAvailable).ToList(); + // Ticket-only pack: every child is TICKET (4) or TICKET_MULTI (5). These are // gifted-currency packs (tutorial starter, throwback) that don't participate in // gacha-point accrual or exchange, even if GachaPointConfig is set in seed. - bool isTicketOnly = p.ChildGachas.All(c => c.TypeDetail == 4 || c.TypeDetail == 5); + bool isTicketOnly = visibleChildren.All(c => c.TypeDetail == 4 || c.TypeDetail == 5); PackGachaPointDto? gachaPointDto = null; if (p.GachaPointConfig is not null && !isTicketOnly) @@ -145,7 +171,7 @@ public class PackController : SVSimController DialogTitle = b.DialogTitle, }).ToList(), GachaDetail = p.GachaDetail, - ChildGachaInfo = p.ChildGachas.Select(c => new PackChildGachaDto + ChildGachaInfo = visibleChildren.Select(c => new PackChildGachaDto { GachaId = c.GachaId, TypeDetail = c.TypeDetail, @@ -164,6 +190,14 @@ public class PackController : SVSimController : 0, IsDailySingle = c.IsDailySingle, OverrideIncreaseGachaPoint = c.OverrideIncreaseGachaPoint.ToString(CultureInfo.InvariantCulture), + CampaignName = c.CampaignName, + PurchaseLimitCount = c.PurchaseLimitCount > 0 + ? c.PurchaseLimitCount.ToString(CultureInfo.InvariantCulture) + : null, + DailyFreeGachaCount = c.DailyFreeGachaCount > 0 + ? c.DailyFreeGachaCount.ToString(CultureInfo.InvariantCulture) + : null, + FreeGachaCampaignId = c.FreeGachaCampaignId, }).ToList(), OpenCount = openCount, OpenCountLimit = p.OpenCountLimit, diff --git a/SVSim.UnitTests/Controllers/PackControllerInfoTests.cs b/SVSim.UnitTests/Controllers/PackControllerInfoTests.cs index ae67eb2..86a0638 100644 --- a/SVSim.UnitTests/Controllers/PackControllerInfoTests.cs +++ b/SVSim.UnitTests/Controllers/PackControllerInfoTests.cs @@ -210,4 +210,93 @@ public class PackControllerInfoTests "ticket-only pack must not emit a gacha_point block"); } } + + [Test] + public async Task Info_includes_free_pack_child_when_no_claim_today() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Packs.Add(new PackConfigEntry + { + Id = 80032, BasePackId = 80001, PackCategory = PackCategory.LegendCardPack, + CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30), + GachaType = 1, GachaDetail = "throwback test", SleeveId = 5090001, + ChildGachas = + { + new PackChildGachaEntry { GachaId = 800032, TypeDetail = 5, Cost = 1, CardCount = 8, ItemId = 80001 }, + new PackChildGachaEntry + { + GachaId = 780032, TypeDetail = 10, Cost = 1, CardCount = 8, + PurchaseLimitCount = 1, DailyFreeGachaCount = 1, + FreeGachaCampaignId = 49, CampaignName = "Test Campaign", + }, + }, + }); + await db.SaveChangesAsync(); + } + + using var client = factory.CreateAuthenticatedClient(viewerId); + var response = await client.PostAsync("/pack/info", JsonBody(EmptyEnvelope)); + var body = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); + + using var doc = JsonDocument.Parse(body); + var pack = doc.RootElement.GetProperty("pack_config_list").EnumerateArray() + .Single(p => p.GetProperty("parent_gacha_id").GetInt32() == 80032); + var children = pack.GetProperty("child_gacha_info").EnumerateArray().ToList(); + Assert.That(children.Count, Is.EqualTo(2), "free + ticket children both visible pre-claim"); + var free = children.Single(c => c.GetProperty("type_detail").GetInt32() == 10); + Assert.That(free.GetProperty("free_gacha_campaign_id").GetInt32(), Is.EqualTo(49)); + Assert.That(free.GetProperty("campaign_name").GetString(), Is.EqualTo("Test Campaign")); + Assert.That(free.GetProperty("daily_free_gacha_count").GetString(), Is.EqualTo("1")); + Assert.That(free.GetProperty("purchase_limit_count").GetString(), Is.EqualTo("1")); + } + + [Test] + public async Task Info_drops_free_pack_child_when_claimed_today() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Packs.Add(new PackConfigEntry + { + Id = 80033, BasePackId = 80001, PackCategory = PackCategory.LegendCardPack, + CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30), + GachaType = 1, GachaDetail = "throwback test", SleeveId = 5090001, + ChildGachas = + { + new PackChildGachaEntry { GachaId = 800033, TypeDetail = 5, Cost = 1, CardCount = 8, ItemId = 80001 }, + new PackChildGachaEntry + { + GachaId = 780033, TypeDetail = 10, Cost = 1, CardCount = 8, + DailyFreeGachaCount = 1, FreeGachaCampaignId = 50, CampaignName = "X", + }, + }, + }); + var v = await db.Viewers.FirstAsync(x => x.Id == viewerId); + v.FreePackClaims.Add(new ViewerFreePackClaim + { + FreeGachaCampaignId = 50, ClaimCount = 1, LastClaimedAt = DateTime.UtcNow, + }); + await db.SaveChangesAsync(); + } + + using var client = factory.CreateAuthenticatedClient(viewerId); + var response = await client.PostAsync("/pack/info", JsonBody(EmptyEnvelope)); + var body = await response.Content.ReadAsStringAsync(); + + using var doc = JsonDocument.Parse(body); + var pack = doc.RootElement.GetProperty("pack_config_list").EnumerateArray() + .Single(p => p.GetProperty("parent_gacha_id").GetInt32() == 80033); + var children = pack.GetProperty("child_gacha_info").EnumerateArray().ToList(); + Assert.That(children.Count, Is.EqualTo(1), "Only the ticket child should remain after today's free claim"); + Assert.That(children[0].GetProperty("type_detail").GetInt32(), Is.EqualTo(5)); + } }