feat(packs): accept all currently-supported currencies on /pack/open

Extends /pack/open beyond the v1 CRYSTAL_MULTI=2 / DAILY=3 / RUPY_MULTI=7
trio to cover every type_detail whose payment primitive already exists:

  1 CRYSTAL              -> ICurrencySpendService crystal debit
  6 RUPY                 -> ICurrencySpendService rupee debit
  4 TICKET / 5 TICKET_MULTI -> debit child.ItemId from OwnedItemEntry
                            (ticketsNeeded = cost * packNumber), 400 on
                            missing/short balance; reward_list gets a
                            RewardType=4 post-state Item entry to mirror
                            project_wire_reward_list_post_state

Skin-overload type_details (8/9/13) and free-pack overlays (10/11/12)
stay 501 — they need selection / banner plumbing the current code
doesn't have.

Tutorial alias unchanged: it still consumes the gating ticket post-draw
and stamps tutorial_step=100. The two ticket flows diverge by intent
(tutorial = free server-grant; normal = paid by ticket inventory).

Removed Open_rejects_ticket_type_detail (asserted the old 501 path);
covered by Open_rejects_insufficient_tickets. Updated
NonTutorial_pack_open_does_not_emit_tutorial_step to assert the new
200-on-ticket-success behavior — same invariant under test.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-30 23:17:11 -04:00
parent d9d29fbfea
commit 9c9d0fc41f
3 changed files with 256 additions and 42 deletions

View File

@@ -153,30 +153,6 @@ public class PackControllerOpenTests
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotImplemented));
}
[Test]
public async Task Open_rejects_ticket_type_detail()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.Packs.Add(new PackConfigEntry
{
Id = 92001, BasePackId = 90001, PackCategory = PackCategory.SpecialCardPack,
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
GachaType = 1, GachaDetail = "ticket-only pack", SleeveId = 5090001,
ChildGachas = { new PackChildGachaEntry { GachaId = 920002, TypeDetail = 5, Cost = 1, CardCount = 8, ItemId = 92001 } },
});
await db.SaveChangesAsync();
}
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":92001,"gacha_id":920002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}""";
var response = await client.PostAsync("/pack/open", JsonBody(json));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotImplemented));
}
[Test]
public async Task Open_rejects_insufficient_rupees()
{
@@ -232,6 +208,195 @@ public class PackControllerOpenTests
}
}
[Test]
public async Task Open_with_crystal_single_deducts_crystals()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
int baseId = await db.CardSets.Where(s => s.Cards.Count > 0).Select(s => s.Id).FirstAsync();
db.Packs.Add(new PackConfigEntry
{
Id = 10001, BasePackId = baseId, PackCategory = PackCategory.None,
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
GachaType = 1, GachaDetail = "crystal single",
// type_detail=1 (CRYSTAL single-pack) — new path added 2026-05-31.
ChildGachas = { new PackChildGachaEntry { GachaId = 100012, TypeDetail = 1, Cost = 120, CardCount = 8 } },
});
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
v.Currency.Crystals = 200;
await db.SaveChangesAsync();
}
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":100012,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}""";
var response = await client.PostAsync("/pack/open", JsonBody(json));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), await response.Content.ReadAsStringAsync());
using var scope2 = factory.Services.CreateScope();
var db2 = scope2.ServiceProvider.GetRequiredService<SVSimDbContext>();
Assert.That((await db2.Viewers.FirstAsync(x => x.Id == viewerId)).Currency.Crystals, Is.EqualTo(80UL));
}
[Test]
public async Task Open_with_rupy_single_deducts_rupees()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
int baseId = await db.CardSets.Where(s => s.Cards.Count > 0).Select(s => s.Id).FirstAsync();
db.Packs.Add(new PackConfigEntry
{
Id = 10001, BasePackId = baseId, PackCategory = PackCategory.None,
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
GachaType = 1, GachaDetail = "rupy single",
ChildGachas = { new PackChildGachaEntry { GachaId = 600006, TypeDetail = 6, Cost = 100, CardCount = 8 } },
});
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
v.Currency.Rupees = 250;
await db.SaveChangesAsync();
}
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":600006,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}""";
var response = await client.PostAsync("/pack/open", JsonBody(json));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), await response.Content.ReadAsStringAsync());
using var scope2 = factory.Services.CreateScope();
var db2 = scope2.ServiceProvider.GetRequiredService<SVSimDbContext>();
Assert.That((await db2.Viewers.FirstAsync(x => x.Id == viewerId)).Currency.Rupees, Is.EqualTo(150UL));
}
[Test]
public async Task Open_with_ticket_consumes_ticket_and_emits_post_state_item_count()
{
const int TicketItemId = 90001;
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await factory.SeedOwnedItemAsync(viewerId, itemId: TicketItemId, count: 3, itemName: "Legendary Card Pack Ticket");
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
int baseId = await db.CardSets.Where(s => s.Cards.Count > 0).Select(s => s.Id).FirstAsync();
db.Packs.Add(new PackConfigEntry
{
Id = 10001, BasePackId = baseId, PackCategory = PackCategory.None,
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
GachaType = 1, GachaDetail = "ticket pack",
// type_detail=4 (TICKET single) — 1 ticket per pack.
ChildGachas = { new PackChildGachaEntry { GachaId = 100014, TypeDetail = 4, Cost = 1, CardCount = 8, ItemId = TicketItemId } },
});
await db.SaveChangesAsync();
}
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":100014,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}""";
var response = await client.PostAsync("/pack/open", JsonBody(json));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
// Ticket count should be 2 (started at 3, consumed 1).
using var scope2 = factory.Services.CreateScope();
var db2 = scope2.ServiceProvider.GetRequiredService<SVSimDbContext>();
var owned = await db2.Viewers
.Where(v => v.Id == viewerId)
.SelectMany(v => v.Items)
.Where(i => i.Item.Id == TicketItemId)
.Select(i => i.Count)
.FirstAsync();
Assert.That(owned, Is.EqualTo(2));
// reward_list must include the post-state Item entry (RewardType=4) so the client
// direct-assigns _userItemDict — same post-state convention as the tutorial path.
using var doc = JsonDocument.Parse(body);
var rewardList = doc.RootElement.GetProperty("reward_list");
var ticketEntry = rewardList.EnumerateArray()
.FirstOrDefault(e => e.GetProperty("reward_type").GetInt32() == 4
&& e.GetProperty("reward_id").GetInt64() == TicketItemId);
Assert.That(ticketEntry.ValueKind, Is.Not.EqualTo(JsonValueKind.Undefined),
"ticket reward entry missing from reward_list");
Assert.That(ticketEntry.GetProperty("reward_num").GetInt32(), Is.EqualTo(2),
"RewardNum must be post-state total, not delta");
}
[Test]
public async Task Open_with_ticket_multi_consumes_one_ticket_per_pack()
{
const int TicketItemId = 90001;
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await factory.SeedOwnedItemAsync(viewerId, itemId: TicketItemId, count: 10, itemName: "Bulk Ticket");
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
int baseId = await db.CardSets.Where(s => s.Cards.Count > 0).Select(s => s.Id).FirstAsync();
db.Packs.Add(new PackConfigEntry
{
Id = 10001, BasePackId = baseId, PackCategory = PackCategory.None,
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
GachaType = 1, GachaDetail = "10-pack ticket",
// type_detail=5 (TICKET_MULTI 10-pack) — cost=1 ticket per pack, packNumber=10
// => 10 tickets consumed total.
ChildGachas = { new PackChildGachaEntry { GachaId = 100015, TypeDetail = 5, Cost = 1, CardCount = 8, ItemId = TicketItemId } },
});
await db.SaveChangesAsync();
}
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":100015,"gacha_type":1,"pack_number":10,"exclude_card_ids":[]}""";
var response = await client.PostAsync("/pack/open", JsonBody(json));
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 owned = await db2.Viewers
.Where(v => v.Id == viewerId)
.SelectMany(v => v.Items)
.Where(i => i.Item.Id == TicketItemId)
.Select(i => i.Count)
.FirstAsync();
Assert.That(owned, Is.EqualTo(0), "10 tickets started, 10-pack buy consumes 10");
}
[Test]
public async Task Open_rejects_insufficient_tickets()
{
const int TicketItemId = 90001;
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await factory.SeedOwnedItemAsync(viewerId, itemId: TicketItemId, count: 0, itemName: "Ticket");
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
int baseId = await db.CardSets.Where(s => s.Cards.Count > 0).Select(s => s.Id).FirstAsync();
db.Packs.Add(new PackConfigEntry
{
Id = 10001, BasePackId = baseId, PackCategory = PackCategory.None,
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
GachaType = 1, GachaDetail = "ticket pack",
ChildGachas = { new PackChildGachaEntry { GachaId = 100014, TypeDetail = 4, Cost = 1, CardCount = 8, ItemId = TicketItemId } },
});
await db.SaveChangesAsync();
}
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":100014,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}""";
var response = await client.PostAsync("/pack/open", JsonBody(json));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
var body = await response.Content.ReadAsStringAsync();
Assert.That(body, Does.Contain("insufficient_tickets"));
}
[Test]
public async Task Open_daily_marks_last_daily_free_at_and_rejects_second_attempt()
{