fix(pack): route /tutorial/pack_open through the normal path
The tutorial pack-open alias hardcoded parent_gacha_id=99047 (the May 2026 throwback pair), so once the June rotation replaced it with 99032 the tutorial's final pack-open 400ed. Fix the identity gate by removing it: /tutorial/pack_open is now a plain alias for /pack/open with a single epilogue that advances TutorialState to 100 and emits tutorial_step=100. The tutorial gift's TICKET_MULTI item naturally scopes what the alias can open — the normal path's ticket-debit branch already consumes it, so no separate tutorial-path bypass is needed. Also drop the July-1 expiry on packs 80032/99032 so the current throwback pair stays visible. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -3715,7 +3715,7 @@
|
|||||||
"pack_category": 1,
|
"pack_category": 1,
|
||||||
"poster_type": 0,
|
"poster_type": 0,
|
||||||
"commence_date": "2026-06-01 02:00:00",
|
"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,
|
"sleeve_id": 5090001,
|
||||||
"special_sleeve_id": 0,
|
"special_sleeve_id": 0,
|
||||||
"override_draw_effect_pack_id": 80001,
|
"override_draw_effect_pack_id": 80001,
|
||||||
@@ -3725,7 +3725,7 @@
|
|||||||
"is_new": false,
|
"is_new": false,
|
||||||
"is_pre_release": false,
|
"is_pre_release": false,
|
||||||
"open_count_limit": 0,
|
"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,
|
"gacha_point": null,
|
||||||
"child_gachas": [
|
"child_gachas": [
|
||||||
{
|
{
|
||||||
@@ -3846,7 +3846,7 @@
|
|||||||
"pack_category": 1,
|
"pack_category": 1,
|
||||||
"poster_type": 0,
|
"poster_type": 0,
|
||||||
"commence_date": "2026-06-01 02:00:00",
|
"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,
|
"sleeve_id": 5090001,
|
||||||
"special_sleeve_id": 0,
|
"special_sleeve_id": 0,
|
||||||
"override_draw_effect_pack_id": 90001,
|
"override_draw_effect_pack_id": 90001,
|
||||||
@@ -3856,7 +3856,7 @@
|
|||||||
"is_new": false,
|
"is_new": false,
|
||||||
"is_pre_release": false,
|
"is_pre_release": false,
|
||||||
"open_count_limit": 0,
|
"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,
|
"gacha_point": null,
|
||||||
"child_gachas": [
|
"child_gachas": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -340,16 +340,14 @@ public class PackController : SVSimController
|
|||||||
{
|
{
|
||||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
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");
|
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.
|
// Skin-card overload not implemented; rotation-starter (class_id) IS supported below.
|
||||||
if (request.TargetCardId.HasValue)
|
if (request.TargetCardId.HasValue)
|
||||||
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "skin_overload_not_implemented" });
|
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
|
// 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.
|
// child; gacha_type validation against child.TypeDetail would falsely reject every buy.
|
||||||
|
|
||||||
// Supported on the normal path: Crystal / CrystalMulti -> spend crystals; Rupy /
|
// Supported: Crystal / CrystalMulti -> spend crystals; Rupy / RupyMulti -> spend rupees;
|
||||||
// RupyMulti -> spend rupees; Daily -> spend rupees, once per UTC day; Ticket /
|
// Daily -> spend rupees, once per UTC day; Ticket / TicketMulti -> consume child.ItemId
|
||||||
// TicketMulti -> consume child.ItemId from OwnedItemEntry; FreePacks -> no debit,
|
// from OwnedItemEntry; FreePacks -> no debit, gated by per-campaign daily quota.
|
||||||
// gated by per-campaign daily quota.
|
|
||||||
// CrystalSpecial / CrystalSelectSkin / CrystalAcquireSkinCardPack and the
|
// CrystalSpecial / CrystalSelectSkin / CrystalAcquireSkinCardPack and the
|
||||||
// FreePackWithSkin / RotationStarterPack overlays need extra selection / banner
|
// FreePackWithSkin / RotationStarterPack overlays need extra selection / banner
|
||||||
// plumbing — kept 501 until the relevant flows land.
|
// 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.Crystal or CardPackType.CrystalMulti or CardPackType.Daily or
|
||||||
CardPackType.Ticket or CardPackType.TicketMulti or CardPackType.Rupy or
|
CardPackType.Ticket or CardPackType.TicketMulti or CardPackType.Rupy or
|
||||||
CardPackType.RupyMulti or CardPackType.FreePacks))
|
CardPackType.RupyMulti or CardPackType.FreePacks))
|
||||||
@@ -426,110 +423,97 @@ public class PackController : SVSimController
|
|||||||
});
|
});
|
||||||
var viewer = tx.Viewer;
|
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;
|
const int TutorialEndStep = 100;
|
||||||
if (isTutorialPath && viewer.MissionData.TutorialState >= TutorialEndStep)
|
|
||||||
return BadRequest(new { error = "tutorial_already_complete" });
|
|
||||||
|
|
||||||
int packNumber = Math.Max(1, request.PackNumber);
|
int packNumber = Math.Max(1, request.PackNumber);
|
||||||
|
|
||||||
// Currency check + deduction (skipped for tutorial path — starter pack is free)
|
// Currency check + deduction. TICKET_MULTI is the mechanism the tutorial alias rides
|
||||||
if (!isTutorialPath)
|
// 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:
|
long cost = (long)child.Cost * packNumber;
|
||||||
case CardPackType.CrystalMulti:
|
var r = await tx.TrySpendAsync(SpendCurrency.Crystal, cost);
|
||||||
{
|
if (!r.Success) return BadRequest(new { error = "insufficient_crystals" });
|
||||||
long cost = (long)child.Cost * packNumber;
|
break;
|
||||||
var r = await tx.TrySpendAsync(SpendCurrency.Crystal, cost);
|
}
|
||||||
if (!r.Success) return BadRequest(new { error = "insufficient_crystals" });
|
case CardPackType.Rupy:
|
||||||
break;
|
case CardPackType.RupyMulti:
|
||||||
}
|
{
|
||||||
case CardPackType.Rupy:
|
long cost = (long)child.Cost * packNumber;
|
||||||
case CardPackType.RupyMulti:
|
var r = await tx.TrySpendAsync(SpendCurrency.Rupee, cost);
|
||||||
{
|
if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
|
||||||
long cost = (long)child.Cost * packNumber;
|
break;
|
||||||
var r = await tx.TrySpendAsync(SpendCurrency.Rupee, cost);
|
}
|
||||||
if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
|
case CardPackType.Daily:
|
||||||
break;
|
{
|
||||||
}
|
// DAILY is the once-per-day half-off crystal single-pack (GachaUI.cs:1046 →
|
||||||
case CardPackType.Daily:
|
// CheckBuyPackWithCrystal, decompile-confirmed). Currency is Crystal, NOT Rupee.
|
||||||
{
|
var existing = viewer.PackOpenCounts.FirstOrDefault(p => p.PackId == pack.Id);
|
||||||
// DAILY is the once-per-day half-off crystal single-pack (GachaUI.cs:1046 →
|
if (!_calendar.ResetReady(existing?.LastDailyFreeAt))
|
||||||
// CheckBuyPackWithCrystal, decompile-confirmed). Currency is Crystal, NOT Rupee.
|
return BadRequest(new { error = "daily_free_already_claimed" });
|
||||||
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;
|
long cost = (long)child.Cost * packNumber;
|
||||||
var r = await tx.TrySpendAsync(SpendCurrency.Crystal, cost);
|
var r = await tx.TrySpendAsync(SpendCurrency.Crystal, cost);
|
||||||
if (!r.Success) return BadRequest(new { error = "insufficient_crystals" });
|
if (!r.Success) return BadRequest(new { error = "insufficient_crystals" });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case CardPackType.Ticket:
|
case CardPackType.Ticket:
|
||||||
case CardPackType.TicketMulti:
|
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)
|
viewer.FreePackClaims.Add(new ViewerFreePackClaim
|
||||||
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
|
FreeGachaCampaignId = campaignId,
|
||||||
{
|
ClaimCount = 1,
|
||||||
FreeGachaCampaignId = campaignId,
|
LastClaimedAt = DateTime.UtcNow,
|
||||||
ClaimCount = 1,
|
});
|
||||||
LastClaimedAt = DateTime.UtcNow,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else if (resetSinceLastClaim)
|
|
||||||
{
|
|
||||||
existing.ClaimCount = 1;
|
|
||||||
existing.LastClaimedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
existing.ClaimCount++;
|
|
||||||
existing.LastClaimedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
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.
|
await _packs.IncrementOpenCount(viewerId, pack.Id, packNumber);
|
||||||
// Tutorial path skips these — the starter pack is a one-time free grant, not a
|
if (child.TypeDetail == CardPackType.Daily)
|
||||||
// purchasable/trackable open.
|
|
||||||
if (!isTutorialPath)
|
|
||||||
{
|
{
|
||||||
await _packs.IncrementOpenCount(viewerId, pack.Id, packNumber);
|
await _packs.MarkDailyFreeUsed(viewerId, pack.Id, DateTime.UtcNow);
|
||||||
if (child.TypeDetail == CardPackType.Daily)
|
|
||||||
{
|
|
||||||
await _packs.MarkDailyFreeUsed(viewerId, pack.Id, DateTime.UtcNow);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw + persist. DAILY single overrides packNumber to 1 (it's a one-card open).
|
// 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))
|
foreach (var grp in draw.Cards.GroupBy(c => c.CardId))
|
||||||
await tx.GrantAsync(UserGoodsType.Card, grp.Key, grp.Count());
|
await tx.GrantAsync(UserGoodsType.Card, grp.Key, grp.Count());
|
||||||
|
|
||||||
// Accrue gacha points (skip tutorial path — the starter pack isn't a real open).
|
_gachaPoint.Accrue(viewer, pack, child, drawCount);
|
||||||
if (!isTutorialPath)
|
|
||||||
{
|
|
||||||
_gachaPoint.Accrue(viewer, pack, child, drawCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tutorial path consumes the granted ticket (same item_id used to gate display) so the
|
// Tutorial alias epilogue: advance TutorialState to END (max-preserve so a higher
|
||||||
// pack drops out of /tutorial/pack_info on next refresh. Without this, the pack still
|
// sentinel is never regressed) and emit tutorial_step=100 on the wire. The client's
|
||||||
// shows item_number=1 after the tutorial pack-open, the client lets the user re-click
|
// PackOpenTask.Parse runs _userTutorial.Update on the response — this is the END
|
||||||
// it, and the second click hits /pack/open (not /tutorial/pack_open) — which 501s on
|
// signal that transitions the client out of the tutorial.
|
||||||
// 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).
|
|
||||||
int? responseTutorialStep = null;
|
int? responseTutorialStep = null;
|
||||||
if (isTutorialPath)
|
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)
|
if (viewer.MissionData.TutorialState < TutorialEndStep)
|
||||||
viewer.MissionData.TutorialState = TutorialEndStep;
|
viewer.MissionData.TutorialState = TutorialEndStep;
|
||||||
responseTutorialStep = TutorialEndStep;
|
responseTutorialStep = TutorialEndStep;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CommitAsync saves all mutations and produces reward_list with currency-collision resolved.
|
// 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 result = await tx.CommitAsync(HttpContext.RequestAborted);
|
||||||
var rewardList = result.RewardList.ToRewardList();
|
var rewardList = result.RewardList.ToRewardList();
|
||||||
|
|
||||||
|
|||||||
@@ -727,71 +727,6 @@ public class PackControllerOpenTests
|
|||||||
Assert.That(v.Currency.Rupees, Is.EqualTo(0UL), "freeplay must not deduct real DB balance");
|
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<SVSimDbContext>();
|
|
||||||
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<SVSimDbContext>();
|
|
||||||
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) ----------------
|
// ---------------- Free pack (type_detail=10) ----------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -181,69 +181,43 @@ public class PackControllerTests
|
|||||||
"Regular /pack/open must never emit tutorial_step — only /tutorial/pack_open does.");
|
"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]
|
[Test]
|
||||||
public async Task TutorialPackOpen_does_not_downgrade_state_past_100()
|
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
|
// Max-preserve: a state > 100 (e.g., a post-tutorial training sentinel) must not be
|
||||||
// sentinel) must not be clobbered down to 100. Today nothing in prod sets state above 100,
|
// clobbered down to 100. Nothing in prod sets state above 100 today, so synthesize
|
||||||
// so synthesize the case directly.
|
// 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();
|
using var factory = new SVSimTestFactory();
|
||||||
await factory.SeedGlobalsAsync();
|
await factory.SeedGlobalsAsync();
|
||||||
long viewerId = await factory.SeedViewerAsync(tutorialState: 200);
|
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<SVSimDbContext>();
|
||||||
|
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 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",
|
var response = await client.PostAsync("/tutorial/pack_open",
|
||||||
new StringContent(requestJson, Encoding.UTF8, "application/json"));
|
new StringContent(requestJson, Encoding.UTF8, "application/json"));
|
||||||
|
|
||||||
// Either the request is rejected (because state>=100, see Gate B above), OR — if the
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
// implementation reads the gate differently — at minimum the persisted state must not
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||||
// regress. Encode the load-bearing invariant: state never goes backwards.
|
|
||||||
Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.GreaterThanOrEqualTo(200),
|
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.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user