feat(pack): /pack/open supports type_detail=10 FREE_PACKS with per-campaign daily quota
This commit is contained in:
@@ -305,14 +305,15 @@ public class PackController : SVSimController
|
|||||||
// 4 TICKET / 5 TICKET_MULTI -> consume child.ItemId from OwnedItemEntry
|
// 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
|
// 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.
|
// 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" });
|
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "currency_path_not_implemented" });
|
||||||
|
|
||||||
// Load viewer via InventoryService transaction with extra includes for pack-open needs.
|
// Load viewer via InventoryService transaction with extra includes for pack-open needs.
|
||||||
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted, cfg => cfg
|
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted, cfg => cfg
|
||||||
.WithInclude(v => v.PackOpenCounts)
|
.WithInclude(v => v.PackOpenCounts)
|
||||||
.WithInclude(v => v.GachaPointBalances)
|
.WithInclude(v => v.GachaPointBalances)
|
||||||
.WithInclude(v => v.MissionData));
|
.WithInclude(v => v.MissionData)
|
||||||
|
.WithInclude(v => v.FreePackClaims));
|
||||||
var viewer = tx.Viewer;
|
var viewer = tx.Viewer;
|
||||||
|
|
||||||
// Tutorial alias is only valid pre-END. After state>=100 the viewer has already
|
// 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" });
|
if (!debit.Success) return BadRequest(new { error = "insufficient_tickets" });
|
||||||
break;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -777,4 +777,107 @@ public class PackControllerOpenTests
|
|||||||
.FirstAsync(x => x.Id == viewerId);
|
.FirstAsync(x => x.Id == viewerId);
|
||||||
Assert.That(v.GachaPointBalances, Is.Empty, "tutorial path must not accrue gacha points");
|
Assert.That(v.GachaPointBalances, Is.Empty, "tutorial path must not accrue gacha points");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------- Free pack (type_detail=10) ----------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task SeedFreePack(SVSimTestFactory f)
|
||||||
|
{
|
||||||
|
long[] cardIds;
|
||||||
|
using (var scope = f.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
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<SVSimDbContext>();
|
||||||
|
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<SVSimDbContext>();
|
||||||
|
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<SVSimDbContext>();
|
||||||
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user