From 81fe842c15f7b29febfc5fc9d9ddbe9dbdec341d Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 4 Jul 2026 11:23:51 -0400 Subject: [PATCH] fix(pack): daily half-off single spends crystals and gates re-purchase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DAILY child gacha (type_detail=3, is_daily_single=true) is the once-per-day half-off crystal single-pack. Two bugs: 1. /pack/open Daily case was calling TrySpendAsync(Rupee) — decompiled client (GachaUI.cs:1046 CheckBuyPackWithCrystal, :1187 SetClystalConfirmDialog) confirms it's a crystal purchase. Freeplay mode masked the mismatch in testing. Swapped to Crystal + insufficient_crystals error. 2. /pack/info kept emitting is_daily_single=true after the viewer's first claim, so the client re-showed the half-off button and a second click 400'd with daily_free_already_claimed. Added a per-parent-pack gate that flips is_daily_single false when LastDailyFreeAt.Date == today. The child entry itself stays so the CRYSTAL_MULTI full-price button still activates. TODO(daily-reset) comments now reference docs/plans/daily-reset-service.md (in the outer repo) — the current UTC-midnight comparison will migrate to the JST 02:00 reset boundary via that plan. Updated the existing daily test to use cost=50 and assert Crystals: 200 -> 150 + Rupees untouched (previously used cost=0 so the currency choice was undetectable). Added two /pack/info gate tests. Co-Authored-By: Claude Opus 4.7 --- .../Controllers/PackController.cs | 25 ++++-- .../Controllers/PackControllerInfoTests.cs | 83 +++++++++++++++++++ .../Controllers/PackControllerOpenTests.cs | 19 ++++- 3 files changed, 118 insertions(+), 9 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs index 437988e0..d48a543a 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs @@ -130,11 +130,22 @@ public class PackController : SVSimController { int openCount = openCounts.TryGetValue(p.Id, out var oc) ? oc.OpenCount : 0; + // Suppress the daily-single half-off flag once the viewer has claimed this parent + // pack's DAILY child today. The client's UI is entirely wire-driven — it renders the + // half-off button iff `is_daily_single: true` (GachaPackAreaLayout.cs:420, + // PackInfoTask.cs:143 → PackChildGachaInfo.IsDailySingle). Without this gate the button + // stays visible after the first successful open and a second click 400s with + // `daily_free_already_claimed`. Keep the child entry itself so the CRYSTAL_MULTI + // full-price button still activates. + // TODO(daily-reset): using UTC midnight; migrate to the JST-boundary DailyReset service + // when it lands (see docs/plans/daily-reset-service.md). + var today = DateTime.UtcNow.Date; + bool dailyClaimedToday = oc?.LastDailyFreeAt is DateTime lastDaily && lastDaily.Date == today; + // 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 != CardPackType.FreePacks) return true; @@ -201,7 +212,7 @@ public class PackController : SVSimController ItemNumber = c.ItemId is long iid && ownedItemsByItemId.TryGetValue(iid, out var ownedCount) ? ownedCount : 0, - IsDailySingle = c.IsDailySingle, + IsDailySingle = c.IsDailySingle && !(c.TypeDetail == CardPackType.Daily && dailyClaimedToday), OverrideIncreaseGachaPoint = c.OverrideIncreaseGachaPoint.ToString(CultureInfo.InvariantCulture), CampaignName = c.CampaignName, PurchaseLimitCount = c.PurchaseLimitCount > 0 @@ -448,16 +459,18 @@ public class PackController : SVSimController } case CardPackType.Daily: { - // TODO(daily-reset): no project-wide daily-reset convention exists yet. Using UTC - // midnight; revisit when the global reset boundary is settled. + // DAILY is the once-per-day half-off crystal single-pack (GachaUI.cs:1046 → + // CheckBuyPackWithCrystal, decompile-confirmed). Currency is Crystal, NOT Rupee. + // TODO(daily-reset): using UTC midnight; migrate to the JST-boundary + // DailyReset service when it lands (see docs/plans/daily-reset-service.md). var now = DateTime.UtcNow; var existing = viewer.PackOpenCounts.FirstOrDefault(p => p.PackId == pack.Id); if (existing?.LastDailyFreeAt is DateTime last && last.Date == now.Date) return BadRequest(new { error = "daily_free_already_claimed" }); long cost = (long)child.Cost * packNumber; - var r = await tx.TrySpendAsync(SpendCurrency.Rupee, cost); - if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); + var r = await tx.TrySpendAsync(SpendCurrency.Crystal, cost); + if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); break; } case CardPackType.Ticket: diff --git a/SVSim.UnitTests/Controllers/PackControllerInfoTests.cs b/SVSim.UnitTests/Controllers/PackControllerInfoTests.cs index 21b1cdaa..5095b9cc 100644 --- a/SVSim.UnitTests/Controllers/PackControllerInfoTests.cs +++ b/SVSim.UnitTests/Controllers/PackControllerInfoTests.cs @@ -299,4 +299,87 @@ public class PackControllerInfoTests 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)); } + + [Test] + public async Task Info_emits_is_daily_single_true_before_todays_claim() + { + // Client's DAILY-branch UI (GachaPackAreaLayout.cs:420) renders the half-off button + // iff the wire ships `is_daily_single: true`. Pre-claim it must be truthy. + 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 = 10001, BasePackId = 10001, PackCategory = PackCategory.None, + CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30), + GachaType = 1, GachaDetail = "daily test", + ChildGachas = + { + new PackChildGachaEntry { GachaId = 200001, TypeDetail = CardPackType.Daily, Cost = 50, CardCount = 8, IsDailySingle = true }, + new PackChildGachaEntry { GachaId = 100002, TypeDetail = CardPackType.CrystalMulti, Cost = 100, CardCount = 8 }, + }, + }); + 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 daily = doc.RootElement.GetProperty("pack_config_list")[0] + .GetProperty("child_gacha_info").EnumerateArray() + .Single(c => c.GetProperty("type_detail").GetInt32() == 3); + Assert.That(daily.TryGetProperty("is_daily_single", out var flag), Is.True, + "is_daily_single must be present when claimable"); + Assert.That(flag.GetBoolean(), Is.True); + } + + [Test] + public async Task Info_suppresses_is_daily_single_after_todays_claim() + { + // Bug repro: after a successful DAILY open, /pack/info kept emitting is_daily_single=true, + // so the client re-showed the half-off button. Clicking it again 400'd with + // daily_free_already_claimed. Post-claim the flag must be false (or omitted) so the + // client's DAILY-branch renders the full-price crystal button instead. The child entry + // itself stays so CRYSTAL_MULTI still activates. + 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 = 10001, BasePackId = 10001, PackCategory = PackCategory.None, + CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30), + GachaType = 1, GachaDetail = "daily test", + ChildGachas = + { + new PackChildGachaEntry { GachaId = 200001, TypeDetail = CardPackType.Daily, Cost = 50, CardCount = 8, IsDailySingle = true }, + new PackChildGachaEntry { GachaId = 100002, TypeDetail = CardPackType.CrystalMulti, Cost = 100, CardCount = 8 }, + }, + }); + var v = await db.Viewers.Include(x => x.PackOpenCounts).FirstAsync(x => x.Id == viewerId); + v.PackOpenCounts.Add(new ViewerPackOpenCount { PackId = 10001, OpenCount = 1, LastDailyFreeAt = 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 children = doc.RootElement.GetProperty("pack_config_list")[0] + .GetProperty("child_gacha_info").EnumerateArray().ToList(); + Assert.That(children.Count, Is.EqualTo(2), "child entries stay; only the daily-single flag flips"); + + var daily = children.Single(c => c.GetProperty("type_detail").GetInt32() == 3); + // With WhenWritingDefault, the wire omits the key entirely when false — either shape is + // fine (PackInfoTask.cs:143-150 defaults to false on missing key). + bool value = daily.TryGetProperty("is_daily_single", out var flag) && flag.GetBoolean(); + Assert.That(value, Is.False, + "is_daily_single must be false/absent post-claim so the client stops offering the half-off button"); + } } diff --git a/SVSim.UnitTests/Controllers/PackControllerOpenTests.cs b/SVSim.UnitTests/Controllers/PackControllerOpenTests.cs index 5956238d..5a6eab87 100644 --- a/SVSim.UnitTests/Controllers/PackControllerOpenTests.cs +++ b/SVSim.UnitTests/Controllers/PackControllerOpenTests.cs @@ -399,8 +399,10 @@ public class PackControllerOpenTests } [Test] - public async Task Open_daily_marks_last_daily_free_at_and_rejects_second_attempt() + public async Task Open_daily_debits_crystals_and_rejects_second_attempt() { + // DAILY is the once-per-day half-off CRYSTAL single-pack (GachaUI.cs:1046 → + // CheckBuyPackWithCrystal). Cost=50 crystals; second same-day attempt 400s. using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); using (var scope = factory.Services.CreateScope()) @@ -412,8 +414,11 @@ public class PackControllerOpenTests Id = 10001, BasePackId = baseId, PackCategory = PackCategory.None, CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30), GachaType = 1, GachaDetail = "daily test", - ChildGachas = { new PackChildGachaEntry { GachaId = 200001, TypeDetail = CardPackType.Daily, Cost = 0, CardCount = 1, IsDailySingle = true } }, + ChildGachas = { new PackChildGachaEntry { GachaId = 200001, TypeDetail = CardPackType.Daily, Cost = 50, CardCount = 1, IsDailySingle = true } }, }); + var v = await db.Viewers.FirstAsync(x => x.Id == viewerId); + v.Currency.Crystals = 200; + v.Currency.Rupees = 200; await db.SaveChangesAsync(); } @@ -424,9 +429,17 @@ public class PackControllerOpenTests var first = await client.PostAsync("/pack/open", JsonBody(json)); Assert.That(first.StatusCode, Is.EqualTo(HttpStatusCode.OK), await first.Content.ReadAsStringAsync()); + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var v = await db.Viewers.FirstAsync(x => x.Id == viewerId); + Assert.That(v.Currency.Crystals, Is.EqualTo(150UL), "200 starting - 50 daily-single crystal cost"); + Assert.That(v.Currency.Rupees, Is.EqualTo(200UL), "Rupees must not be touched by a DAILY (crystal) open"); + } + var second = await client.PostAsync("/pack/open", JsonBody(json)); Assert.That(second.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), - "Second daily-free open the same UTC day should be rejected."); + "Second daily-single open the same UTC day should be rejected."); } // ---------------- Regression tests for wire-shape quirks ----------------