refactor(pack): migrate PackController to IGameCalendarService

Swaps all four DateTime.UtcNow.Date same-day checks in PackController for
_calendar.ResetReady() — daily-single open, /pack/info daily-single gate,
FreePacks /pack/info visibility, FreePacks /pack/open quota. Removes the
TODO(daily-reset) comments; ToDto promoted from static to instance so it
can see _calendar.

Also fills in GameCalendarConfig.ShippedDefaults() (required by the
[ConfigSection] discovery in SVSimDbContext.EnsureSeedDataAsync — bootstrap
throws without it).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-07-04 11:49:17 -04:00
parent 90d420d97a
commit a6b2c47b98
2 changed files with 14 additions and 15 deletions

View File

@@ -15,4 +15,6 @@ public class GameCalendarConfig
/// WeekKey / MonthKey — treat as a startup constant in production.
/// </summary>
public int DailyResetUtcHour { get; set; } = 0;
public static GameCalendarConfig ShippedDefaults() => new();
}

View File

@@ -34,6 +34,7 @@ public class PackController : SVSimController
private readonly SVSimDbContext _db;
private readonly IInventoryService _inv;
private readonly IGachaPointService _gachaPoint;
private readonly IGameCalendarService _calendar;
public PackController(
IPackRepository packs,
@@ -43,7 +44,8 @@ public class PackController : SVSimController
IRandom rng,
SVSimDbContext db,
IInventoryService inv,
IGachaPointService gachaPoint)
IGachaPointService gachaPoint,
IGameCalendarService calendar)
{
_packs = packs;
_opener = opener;
@@ -53,6 +55,7 @@ public class PackController : SVSimController
_db = db;
_inv = inv;
_gachaPoint = gachaPoint;
_calendar = calendar;
}
[HttpPost("info")]
@@ -120,7 +123,7 @@ public class PackController : SVSimController
};
}
private static PackConfigDto ToDto(
private PackConfigDto ToDto(
PackConfigEntry p,
IReadOnlyDictionary<int, ViewerPackOpenCount> openCounts,
IReadOnlyDictionary<long, int> ownedItemsByItemId,
@@ -137,21 +140,18 @@ public class PackController : SVSimController
// 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;
bool dailyClaimedToday = !_calendar.ResetReady(oc?.LastDailyFreeAt);
// 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.
// Reset happens at the daily boundary — new day resets ClaimCount effectively.
bool ChildAvailable(PackChildGachaEntry c)
{
if (c.TypeDetail != CardPackType.FreePacks) 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;
if (_calendar.ResetReady(claim.LastClaimedAt)) return true;
int dailyCap = c.DailyFreeGachaCount > 0 ? c.DailyFreeGachaCount : 1;
return claim.ClaimCount < dailyCap;
}
@@ -461,11 +461,8 @@ public class PackController : SVSimController
{
// 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)
if (!_calendar.ResetReady(existing?.LastDailyFreeAt))
return BadRequest(new { error = "daily_free_already_claimed" });
long cost = (long)child.Cost * packNumber;
@@ -490,9 +487,9 @@ public class PackController : SVSimController
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)
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.
@@ -508,7 +505,7 @@ public class PackController : SVSimController
LastClaimedAt = DateTime.UtcNow,
});
}
else if (existing.LastClaimedAt.Date != today)
else if (resetSinceLastClaim)
{
existing.ClaimCount = 1;
existing.LastClaimedAt = DateTime.UtcNow;