diff --git a/SVSim.Bootstrap/Data/seeds/packs.json b/SVSim.Bootstrap/Data/seeds/packs.json index fe59d2b2..25d267e4 100644 --- a/SVSim.Bootstrap/Data/seeds/packs.json +++ b/SVSim.Bootstrap/Data/seeds/packs.json @@ -3715,7 +3715,7 @@ "pack_category": 1, "poster_type": 0, "commence_date": "2026-06-01 02:00:00", - "complete_date": "2026-07-01 01:59:59", + "complete_date": "2030-12-31 23:59:59", "sleeve_id": 5090001, "special_sleeve_id": 0, "override_draw_effect_pack_id": 80001, @@ -3725,7 +3725,7 @@ "is_new": false, "is_pre_release": false, "open_count_limit": 0, - "sales_period_time": "2026-07-01 01:59:59", + "sales_period_time": "2030-12-31 23:59:59", "gacha_point": null, "child_gachas": [ { @@ -3846,7 +3846,7 @@ "pack_category": 1, "poster_type": 0, "commence_date": "2026-06-01 02:00:00", - "complete_date": "2026-07-01 01:59:59", + "complete_date": "2030-12-31 23:59:59", "sleeve_id": 5090001, "special_sleeve_id": 0, "override_draw_effect_pack_id": 90001, @@ -3856,7 +3856,7 @@ "is_new": false, "is_pre_release": false, "open_count_limit": 0, - "sales_period_time": "2026-07-01 01:59:59", + "sales_period_time": "2030-12-31 23:59:59", "gacha_point": null, "child_gachas": [ { diff --git a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs index de40ab05..51e6e3ab 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs @@ -340,16 +340,14 @@ public class PackController : SVSimController { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); + // /tutorial/pack_open is a plain alias for /pack/open — the client uses it during the + // tutorial's final "open the starter legendary pack" step. The only wire-level + // difference is that the response carries tutorial_step=100 so the client transitions + // out of the tutorial. Currency/ticket debits, open-count tracking, and pack identity + // all flow through the normal path — the tutorial gift's tickets naturally constrain + // which packs are openable via this alias to the current throwback starter pair. bool isTutorialPath = HttpContext.Request.Path.StartsWithSegments("/tutorial/pack_open"); - // The tutorial alias bypasses the currency / type_detail / open-count guards because - // the legendary starter pack (99047) is a free server-grant during the 41→100 tutorial - // transition. Constrain the alias to that one pack so the bypass isn't a free draw on - // ANY pack the client supplies a parent_gacha_id for. - const int StarterParentGachaId = 99047; - if (isTutorialPath && request.ParentGachaId != StarterParentGachaId) - return BadRequest(new { error = "tutorial_path_only_for_starter_pack" }); - // Skin-card overload not implemented; rotation-starter (class_id) IS supported below. if (request.TargetCardId.HasValue) return StatusCode(StatusCodes.Status501NotImplemented, new { error = "skin_overload_not_implemented" }); @@ -402,14 +400,13 @@ public class PackController : SVSimController // when buying a RUPY_MULTI (type_detail=7) child. The gacha_id alone disambiguates the // child; gacha_type validation against child.TypeDetail would falsely reject every buy. - // Supported on the normal path: Crystal / CrystalMulti -> spend crystals; Rupy / - // RupyMulti -> spend rupees; Daily -> spend rupees, once per UTC day; Ticket / - // TicketMulti -> consume child.ItemId from OwnedItemEntry; FreePacks -> no debit, - // gated by per-campaign daily quota. + // Supported: Crystal / CrystalMulti -> spend crystals; Rupy / RupyMulti -> spend rupees; + // Daily -> spend rupees, once per UTC day; Ticket / TicketMulti -> consume child.ItemId + // from OwnedItemEntry; FreePacks -> no debit, gated by per-campaign daily quota. // CrystalSpecial / CrystalSelectSkin / CrystalAcquireSkinCardPack and the // FreePackWithSkin / RotationStarterPack overlays need extra selection / banner // plumbing — kept 501 until the relevant flows land. - if (!isTutorialPath && child.TypeDetail is not ( + if (child.TypeDetail is not ( CardPackType.Crystal or CardPackType.CrystalMulti or CardPackType.Daily or CardPackType.Ticket or CardPackType.TicketMulti or CardPackType.Rupy or CardPackType.RupyMulti or CardPackType.FreePacks)) @@ -426,110 +423,97 @@ public class PackController : SVSimController }); var viewer = tx.Viewer; - // Tutorial alias is only valid pre-END. After state>=100 the viewer has already - // completed the tutorial — re-running the path would re-consume the ticket they - // chose to keep, and (without the max-preserve write below) could regress a higher - // state value. Mirrors the 31<41 guard in GiftController.TutorialGiftReceive. const int TutorialEndStep = 100; - if (isTutorialPath && viewer.MissionData.TutorialState >= TutorialEndStep) - return BadRequest(new { error = "tutorial_already_complete" }); int packNumber = Math.Max(1, request.PackNumber); - // Currency check + deduction (skipped for tutorial path — starter pack is free) - if (!isTutorialPath) + // Currency check + deduction. TICKET_MULTI is the mechanism the tutorial alias rides + // on: the tutorial gift grants the starter ticket, and this branch consumes it, so no + // separate tutorial-path bypass is needed. + switch (child.TypeDetail) { - switch (child.TypeDetail) + case CardPackType.Crystal: + case CardPackType.CrystalMulti: { - case CardPackType.Crystal: - case CardPackType.CrystalMulti: - { - long cost = (long)child.Cost * packNumber; - var r = await tx.TrySpendAsync(SpendCurrency.Crystal, cost); - if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); - break; - } - case CardPackType.Rupy: - case CardPackType.RupyMulti: - { - long cost = (long)child.Cost * packNumber; - var r = await tx.TrySpendAsync(SpendCurrency.Rupee, cost); - if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); - break; - } - case CardPackType.Daily: - { - // DAILY is the once-per-day half-off crystal single-pack (GachaUI.cs:1046 → - // CheckBuyPackWithCrystal, decompile-confirmed). Currency is Crystal, NOT Rupee. - var existing = viewer.PackOpenCounts.FirstOrDefault(p => p.PackId == pack.Id); - if (!_calendar.ResetReady(existing?.LastDailyFreeAt)) - return BadRequest(new { error = "daily_free_already_claimed" }); + long cost = (long)child.Cost * packNumber; + var r = await tx.TrySpendAsync(SpendCurrency.Crystal, cost); + if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); + break; + } + case CardPackType.Rupy: + case CardPackType.RupyMulti: + { + long cost = (long)child.Cost * packNumber; + var r = await tx.TrySpendAsync(SpendCurrency.Rupee, cost); + if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); + break; + } + case CardPackType.Daily: + { + // DAILY is the once-per-day half-off crystal single-pack (GachaUI.cs:1046 → + // CheckBuyPackWithCrystal, decompile-confirmed). Currency is Crystal, NOT Rupee. + var existing = viewer.PackOpenCounts.FirstOrDefault(p => p.PackId == pack.Id); + if (!_calendar.ResetReady(existing?.LastDailyFreeAt)) + return BadRequest(new { error = "daily_free_already_claimed" }); - long cost = (long)child.Cost * packNumber; - var r = await tx.TrySpendAsync(SpendCurrency.Crystal, cost); - if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); - break; - } - case CardPackType.Ticket: - case CardPackType.TicketMulti: + long cost = (long)child.Cost * packNumber; + var r = await tx.TrySpendAsync(SpendCurrency.Crystal, cost); + if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); + break; + } + case CardPackType.Ticket: + case CardPackType.TicketMulti: + { + if (child.ItemId is not long ticketItemId) + return StatusCode(StatusCodes.Status501NotImplemented, new { error = "ticket_pack_missing_item_id" }); + + int ticketsNeeded = child.Cost * packNumber; + var debit = await tx.TryDebitAsync(UserGoodsType.Item, ticketItemId, ticketsNeeded); + if (!debit.Success) return BadRequest(new { error = "insufficient_tickets" }); + break; + } + case CardPackType.FreePacks: + { + 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 existing = viewer.FreePackClaims.FirstOrDefault(c => c.FreeGachaCampaignId == campaignId); + bool resetSinceLastClaim = existing is null || _calendar.ResetReady(existing.LastClaimedAt); + if (existing is not null && !resetSinceLastClaim && 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) { - if (child.ItemId is not long ticketItemId) - return StatusCode(StatusCodes.Status501NotImplemented, new { error = "ticket_pack_missing_item_id" }); - - int ticketsNeeded = child.Cost * packNumber; - var debit = await tx.TryDebitAsync(UserGoodsType.Item, ticketItemId, ticketsNeeded); - if (!debit.Success) return BadRequest(new { error = "insufficient_tickets" }); - break; - } - case CardPackType.FreePacks: - { - 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 existing = viewer.FreePackClaims.FirstOrDefault(c => c.FreeGachaCampaignId == campaignId); - bool resetSinceLastClaim = existing is null || _calendar.ResetReady(existing.LastClaimedAt); - if (existing is not null && !resetSinceLastClaim && 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 { - viewer.FreePackClaims.Add(new ViewerFreePackClaim - { - FreeGachaCampaignId = campaignId, - ClaimCount = 1, - LastClaimedAt = DateTime.UtcNow, - }); - } - else if (resetSinceLastClaim) - { - existing.ClaimCount = 1; - existing.LastClaimedAt = DateTime.UtcNow; - } - else - { - existing.ClaimCount++; - existing.LastClaimedAt = DateTime.UtcNow; - } - break; + FreeGachaCampaignId = campaignId, + ClaimCount = 1, + LastClaimedAt = DateTime.UtcNow, + }); } + else if (resetSinceLastClaim) + { + existing.ClaimCount = 1; + existing.LastClaimedAt = DateTime.UtcNow; + } + else + { + existing.ClaimCount++; + existing.LastClaimedAt = DateTime.UtcNow; + } + break; } } - // Increment open count + mark daily-free timestamp where relevant. - // Tutorial path skips these — the starter pack is a one-time free grant, not a - // purchasable/trackable open. - if (!isTutorialPath) + await _packs.IncrementOpenCount(viewerId, pack.Id, packNumber); + if (child.TypeDetail == CardPackType.Daily) { - await _packs.IncrementOpenCount(viewerId, pack.Id, packNumber); - if (child.TypeDetail == CardPackType.Daily) - { - await _packs.MarkDailyFreeUsed(viewerId, pack.Id, DateTime.UtcNow); - } + await _packs.MarkDailyFreeUsed(viewerId, pack.Id, DateTime.UtcNow); } // Draw + persist. DAILY single overrides packNumber to 1 (it's a one-card open). @@ -562,42 +546,21 @@ public class PackController : SVSimController foreach (var grp in draw.Cards.GroupBy(c => c.CardId)) await tx.GrantAsync(UserGoodsType.Card, grp.Key, grp.Count()); - // Accrue gacha points (skip tutorial path — the starter pack isn't a real open). - if (!isTutorialPath) - { - _gachaPoint.Accrue(viewer, pack, child, drawCount); - } + _gachaPoint.Accrue(viewer, pack, child, drawCount); - // Tutorial path consumes the granted ticket (same item_id used to gate display) so the - // pack drops out of /tutorial/pack_info on next refresh. Without this, the pack still - // shows item_number=1 after the tutorial pack-open, the client lets the user re-click - // it, and the second click hits /pack/open (not /tutorial/pack_open) — which 501s on - // type_detail=5 (TICKET_MULTI is out of scope for the normal path). Emitting the - // post-state count in reward_list direct-assigns the client's _userItemDict so the - // UI also goes stale-safe immediately (client does direct assignment per - // project_wire_reward_list_post_state memory). + // Tutorial alias epilogue: advance TutorialState to END (max-preserve so a higher + // sentinel is never regressed) and emit tutorial_step=100 on the wire. The client's + // PackOpenTask.Parse runs _userTutorial.Update on the response — this is the END + // signal that transitions the client out of the tutorial. int? responseTutorialStep = null; if (isTutorialPath) { - if (child.ItemId is long tutorialTicketItemId) - { - int ticketsToConsume = packNumber; - var debit = await tx.TryDebitAsync(UserGoodsType.Item, tutorialTicketItemId, ticketsToConsume); - // Silently accept if the viewer doesn't have the ticket (already consumed or never granted) - _ = debit; - } - - // Max-preserve: never regress the persisted state, even though Gate B already - // rejected state>=100 above. Belt-and-braces against a future caller that - // bypasses Gate B (refactor, new alias, etc.). Wire still emits 100 — that's - // the tutorial-END signal the client expects. if (viewer.MissionData.TutorialState < TutorialEndStep) viewer.MissionData.TutorialState = TutorialEndStep; responseTutorialStep = TutorialEndStep; } // CommitAsync saves all mutations and produces reward_list with currency-collision resolved. - // Tutorial path never calls TrySpendAsync so no currency op is in the log — correct. var result = await tx.CommitAsync(HttpContext.RequestAborted); var rewardList = result.RewardList.ToRewardList(); diff --git a/SVSim.UnitTests/Controllers/PackControllerOpenTests.cs b/SVSim.UnitTests/Controllers/PackControllerOpenTests.cs index 5a6eab87..b9f6f22d 100644 --- a/SVSim.UnitTests/Controllers/PackControllerOpenTests.cs +++ b/SVSim.UnitTests/Controllers/PackControllerOpenTests.cs @@ -727,71 +727,6 @@ public class PackControllerOpenTests Assert.That(v.Currency.Rupees, Is.EqualTo(0UL), "freeplay must not deduct real DB balance"); } - [Test] - public async Task TutorialPackOpen_does_not_accrue_gacha_points() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - // Seed the starter pack 99047 with a GachaPointConfig set — the tutorial-path skip - // must hold even when the pack is technically point-eligible. - 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 = 99047, IsInRotation = true }; - db.CardSets.Add(set); - for (int i = 0; i < 30; i++) - { - set.Cards.Add(new ShadowverseCardEntry - { - Id = 99047_1010 + i, Name = $"c{i}", - Rarity = (Rarity)((i % 4) + 1), - Class = db.Classes.Local.First(), IsFoil = false, - }); - } - db.Items.Add(new ItemEntry { Id = 90001, Name = "starter-ticket" }); - db.Packs.Add(new PackConfigEntry - { - Id = 99047, BasePackId = 99047, PackCategory = PackCategory.LegendCardPack, - CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30), - GachaType = 1, - GachaPointConfig = new PackGachaPointConfig { ExchangeablePoint = 400, IncreaseGachaPoint = 1 }, - ChildGachas = - { - new PackChildGachaEntry - { - GachaId = 990475, TypeDetail = CardPackType.TicketMulti, Cost = 0, CardCount = 8, - ItemId = 90001, - }, - }, - }); - var viewer = await db.Viewers - .Include(v => v.Items).ThenInclude(i => i.Item) - .FirstAsync(v => v.Id == viewerId); - viewer.Items.Add(new OwnedItemEntry { Item = db.Items.Local.First(), Count = 1, Viewer = viewer }); - viewer.MissionData.TutorialState = 41; // pre-END so the tutorial path is allowed - await db.SaveChangesAsync(); - } - // Install a draw table for 99047 pointing at the 30 seeded card stubs. - var seededCardIds = Enumerable.Range(0, 30).Select(i => (long)(99047_1010 + i)).ToArray(); - await factory.SeedPackDrawTableAsync(99047, seededCardIds); - - using var client = factory.CreateAuthenticatedClient(viewerId); - var body = new StringContent( - """{"parent_gacha_id":99047,"gacha_id":990475,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""", - System.Text.Encoding.UTF8, "application/json"); - var response = await client.PostAsync("/tutorial/pack_open", body); - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), - await response.Content.ReadAsStringAsync()); - - using var scope2 = factory.Services.CreateScope(); - var db2 = scope2.ServiceProvider.GetRequiredService(); - var v = await db2.Viewers - .Include(x => x.GachaPointBalances) - .FirstAsync(x => x.Id == viewerId); - Assert.That(v.GachaPointBalances, Is.Empty, "tutorial path must not accrue gacha points"); - } - // ---------------- Free pack (type_detail=10) ---------------- /// diff --git a/SVSim.UnitTests/Controllers/PackControllerTests.cs b/SVSim.UnitTests/Controllers/PackControllerTests.cs index 11e8f402..9f37cfc1 100644 --- a/SVSim.UnitTests/Controllers/PackControllerTests.cs +++ b/SVSim.UnitTests/Controllers/PackControllerTests.cs @@ -181,69 +181,43 @@ public class PackControllerTests "Regular /pack/open must never emit tutorial_step — only /tutorial/pack_open does."); } - [Test] - public async Task TutorialPackOpen_rejects_non_starter_parent_gacha_id() - { - using var factory = new SVSimTestFactory(); - await factory.SeedGlobalsAsync(); - long viewerId = await factory.SeedViewerAsync(tutorialState: 41); - using var client = factory.CreateAuthenticatedClient(viewerId); - - // Pick any non-99047 parent_gacha_id seeded by SeedGlobalsAsync (10032 is the most - // recent crystal-multi pack in the catalog). The alias must reject it BadRequest. - var requestJson = """{"parent_gacha_id":10032,"gacha_id":100320,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""; - var response = await client.PostAsync("/tutorial/pack_open", - new StringContent(requestJson, Encoding.UTF8, "application/json")); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), - "Tutorial alias must only accept the starter pack (99047); otherwise any authenticated " + - "viewer can draw any pack for free via the currency-bypass tutorial path."); - - // State must NOT have advanced. - Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.EqualTo(41), - "Rejected requests leave TutorialState untouched."); - } - - [Test] - public async Task TutorialPackOpen_rejects_completed_viewer() - { - using var factory = new SVSimTestFactory(); - await factory.SeedGlobalsAsync(); - long viewerId = await factory.SeedViewerAsync(tutorialState: 100); - await factory.SeedOwnedItemAsync(viewerId, itemId: 90001, count: 1, itemName: "Starter Legendary Ticket"); - using var client = factory.CreateAuthenticatedClient(viewerId); - - var requestJson = """{"parent_gacha_id":99047,"gacha_id":990047,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""; - var response = await client.PostAsync("/tutorial/pack_open", - new StringContent(requestJson, Encoding.UTF8, "application/json")); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), - "Tutorial alias must reject viewers past the tutorial-end gate (state>=100); the path " + - "would otherwise re-clobber state and consume a ticket the viewer kept post-tutorial."); - - Assert.That(await factory.GetOwnedItemCountAsync(viewerId, 90001), Is.EqualTo(1), - "Rejected requests do not consume tickets."); - } - [Test] public async Task TutorialPackOpen_does_not_downgrade_state_past_100() { - // This is the max-preserve check. A future state > 100 (e.g., a post-tutorial training - // sentinel) must not be clobbered down to 100. Today nothing in prod sets state above 100, - // so synthesize the case directly. + // Max-preserve: a state > 100 (e.g., a post-tutorial training sentinel) must not be + // clobbered down to 100. Nothing in prod sets state above 100 today, so synthesize + // the case directly. The viewer must have the starter ticket + card pool seeded so + // the path reaches the tutorial epilogue (rather than 400ing before the state write). using var factory = new SVSimTestFactory(); await factory.SeedGlobalsAsync(); long viewerId = await factory.SeedViewerAsync(tutorialState: 200); - using var client = factory.CreateAuthenticatedClient(viewerId); + await factory.SeedOwnedItemAsync(viewerId, itemId: 90001, count: 1, itemName: "Starter Legendary Ticket"); + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.CardSets.Add(new ShadowverseCardSetEntry + { + Id = 90001, Name = "TutorialStarterSet", IsInRotation = true, IsBasic = false, + Cards = + [ + new ShadowverseCardEntry { Id = 90001001L, Name = "StarterCard1", Rarity = Rarity.Bronze }, + new ShadowverseCardEntry { Id = 90001002L, Name = "StarterCard2", Rarity = Rarity.Gold }, + new ShadowverseCardEntry { Id = 90001003L, Name = "StarterCard3", Rarity = Rarity.Legendary }, + ], + }); + await db.SaveChangesAsync(); + } + await factory.SeedPackDrawTableAsync(99047, 90001001L, 90001002L, 90001003L); + + using var client = factory.CreateAuthenticatedClient(viewerId); var requestJson = """{"parent_gacha_id":99047,"gacha_id":990047,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""; var response = await client.PostAsync("/tutorial/pack_open", new StringContent(requestJson, Encoding.UTF8, "application/json")); - // Either the request is rejected (because state>=100, see Gate B above), OR — if the - // implementation reads the gate differently — at minimum the persisted state must not - // regress. Encode the load-bearing invariant: state never goes backwards. + var body = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.GreaterThanOrEqualTo(200), - "TutorialState must not regress regardless of the alias's accept/reject decision."); + "TutorialState must not regress when the alias fires against a viewer past END."); } }