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:
gamer147
2026-07-04 14:21:52 -04:00
parent 9813f82edc
commit 7309488748
4 changed files with 122 additions and 250 deletions

View File

@@ -727,71 +727,6 @@ public class PackControllerOpenTests
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) ----------------
/// <summary>

View File

@@ -181,69 +181,43 @@ public class PackControllerTests
"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]
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
// sentinel) must not be clobbered down to 100. Today nothing in prod sets state above 100,
// so synthesize the case directly.
// Max-preserve: a state > 100 (e.g., a post-tutorial training sentinel) must not be
// clobbered down to 100. Nothing in prod sets state above 100 today, so synthesize
// 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();
await factory.SeedGlobalsAsync();
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 response = await client.PostAsync("/tutorial/pack_open",
new StringContent(requestJson, Encoding.UTF8, "application/json"));
// Either the request is rejected (because state>=100, see Gate B above), OR — if the
// implementation reads the gate differently — at minimum the persisted state must not
// regress. Encode the load-bearing invariant: state never goes backwards.
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
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.");
}
}