diff --git a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs index 5a3b077..ca6dd23 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs @@ -305,14 +305,15 @@ public class PackController : SVSimController // 4 TICKET / 5 TICKET_MULTI -> consume child.ItemId from OwnedItemEntry // Skin-overload types (8/9/13) and free-pack overlays (10/11/12) need extra // selection / banner plumbing — kept 501 until the relevant flows land. - if (!isTutorialPath && child.TypeDetail is not (1 or 2 or 3 or 4 or 5 or 6 or 7)) + if (!isTutorialPath && child.TypeDetail is not (1 or 2 or 3 or 4 or 5 or 6 or 7 or 10)) return StatusCode(StatusCodes.Status501NotImplemented, new { error = "currency_path_not_implemented" }); // Load viewer via InventoryService transaction with extra includes for pack-open needs. await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted, cfg => cfg .WithInclude(v => v.PackOpenCounts) .WithInclude(v => v.GachaPointBalances) - .WithInclude(v => v.MissionData)); + .WithInclude(v => v.MissionData) + .WithInclude(v => v.FreePackClaims)); var viewer = tx.Viewer; // Tutorial alias is only valid pre-END. After state>=100 the viewer has already @@ -371,6 +372,42 @@ public class PackController : SVSimController if (!debit.Success) return BadRequest(new { error = "insufficient_tickets" }); break; } + case 10: // FREE_PACKS — no currency, no ticket; gated by daily quota per campaign + { + if (child.FreeGachaCampaignId is not int campaignId) + return StatusCode(StatusCodes.Status501NotImplemented, new { error = "free_pack_missing_campaign_id" }); + + int dailyCap = child.DailyFreeGachaCount > 0 ? child.DailyFreeGachaCount : 1; + var today = DateTime.UtcNow.Date; + var existing = viewer.FreePackClaims.FirstOrDefault(c => c.FreeGachaCampaignId == campaignId); + if (existing is not null && existing.LastClaimedAt.Date == today && existing.ClaimCount >= dailyCap) + return BadRequest(new { error = "free_pack_already_claimed_today" }); + + // pack_number is forced to 1 — free-pack metadata never authorizes multi-opens. + // The capture shows pack_number=1 even when daily_free_gacha_count=1 == daily quota. + packNumber = 1; + + if (existing is null) + { + viewer.FreePackClaims.Add(new ViewerFreePackClaim + { + FreeGachaCampaignId = campaignId, + ClaimCount = 1, + LastClaimedAt = DateTime.UtcNow, + }); + } + else if (existing.LastClaimedAt.Date != today) + { + existing.ClaimCount = 1; + existing.LastClaimedAt = DateTime.UtcNow; + } + else + { + existing.ClaimCount++; + existing.LastClaimedAt = DateTime.UtcNow; + } + break; + } } } diff --git a/SVSim.UnitTests/Controllers/PackControllerOpenTests.cs b/SVSim.UnitTests/Controllers/PackControllerOpenTests.cs index 06124ce..d116b55 100644 --- a/SVSim.UnitTests/Controllers/PackControllerOpenTests.cs +++ b/SVSim.UnitTests/Controllers/PackControllerOpenTests.cs @@ -777,4 +777,107 @@ public class PackControllerOpenTests .FirstAsync(x => x.Id == viewerId); Assert.That(v.GachaPointBalances, Is.Empty, "tutorial path must not accrue gacha points"); } + + // ---------------- Free pack (type_detail=10) ---------------- + + /// + /// Seeds parent pack 80032 with a single type_detail=10 free child (gacha_id=780032) + /// for free-pack scenarios. Mirrors the shape from traffic_event_crate_free_pack.ndjson. + /// + private static async Task SeedFreePack(SVSimTestFactory f) + { + long[] cardIds; + using (var scope = f.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + int baseId = await db.CardSets.Where(s => s.Cards.Count > 0).Select(s => s.Id).FirstAsync(); + cardIds = await db.CardSets.Where(s => s.Id == baseId) + .SelectMany(s => s.Cards).Select(c => c.Id).ToArrayAsync(); + db.Packs.Add(new PackConfigEntry + { + Id = 80032, BasePackId = baseId, PackCategory = PackCategory.LegendCardPack, + CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30), + GachaType = 1, GachaDetail = "free pack test", SleeveId = 5090001, + ChildGachas = + { + new PackChildGachaEntry + { + GachaId = 780032, TypeDetail = 10, Cost = 1, CardCount = 8, + DailyFreeGachaCount = 1, PurchaseLimitCount = 1, + FreeGachaCampaignId = 49, CampaignName = "Season Bonus", + }, + }, + }); + await db.SaveChangesAsync(); + } + await f.SeedPackDrawTableAsync(80032, cardIds); + } + + private const string FreePackOpenBody = + """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":80032,"gacha_id":780032,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}"""; + + [Test] + public async Task Open_free_pack_returns_8_cards_and_spends_no_currency() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedFreePack(factory); + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var v = await db.Viewers.FirstAsync(x => x.Id == viewerId); + v.Currency.Crystals = 0; + v.Currency.Rupees = 0; + await db.SaveChangesAsync(); + } + + using var client = factory.CreateAuthenticatedClient(viewerId); + var response = await client.PostAsync("/pack/open", JsonBody(FreePackOpenBody)); + var body = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); + + using var doc = JsonDocument.Parse(body); + Assert.That(doc.RootElement.GetProperty("pack_list").GetArrayLength(), Is.EqualTo(8)); + + using var scope2 = factory.Services.CreateScope(); + var db2 = scope2.ServiceProvider.GetRequiredService(); + var v2 = await db2.Viewers.FirstAsync(x => x.Id == viewerId); + Assert.That(v2.Currency.Crystals, Is.EqualTo(0UL), "free pack must not debit crystals"); + Assert.That(v2.Currency.Rupees, Is.EqualTo(0UL), "free pack must not debit rupees"); + } + + [Test] + public async Task Open_free_pack_rejects_second_claim_same_day() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedFreePack(factory); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var first = await client.PostAsync("/pack/open", JsonBody(FreePackOpenBody)); + Assert.That(first.StatusCode, Is.EqualTo(HttpStatusCode.OK), await first.Content.ReadAsStringAsync()); + + var second = await client.PostAsync("/pack/open", JsonBody(FreePackOpenBody)); + Assert.That(second.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), + "Second free-pack open same UTC day must be rejected."); + } + + [Test] + public async Task Open_free_pack_records_free_pack_claim() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedFreePack(factory); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var response = await client.PostAsync("/pack/open", JsonBody(FreePackOpenBody)); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var v = await db.Viewers.Include(x => x.FreePackClaims).FirstAsync(x => x.Id == viewerId); + var claim = v.FreePackClaims.Single(c => c.FreeGachaCampaignId == 49); + Assert.That(claim.ClaimCount, Is.EqualTo(1)); + Assert.That(claim.LastClaimedAt.Date, Is.EqualTo(DateTime.UtcNow.Date)); + } }