fix(pack): daily half-off single spends crystals and gates re-purchase
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user