fix(pack): tutorial pack_open ThenIncludes OwnedItemEntry.Item
Without .ThenInclude(i => i.Item), the OwnedItemEntry.Item nav defaults to a new ItemEntry() with Id=0 (project_ef_nav_include_pitfall), so the FirstOrDefault(i => i.Item.Id == ticketItemId) lookup never matched. The ticket was never decremented and reward_list omitted the post-state entry — on the next /tutorial/pack_info the pack stayed visible and the client re-clicked into plain /pack/open, which 501s on type_detail=5. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -183,7 +183,7 @@ public class PackController : SVSimController
|
|||||||
var viewer = await _db.Viewers
|
var viewer = await _db.Viewers
|
||||||
.Include(v => v.PackOpenCounts)
|
.Include(v => v.PackOpenCounts)
|
||||||
.Include(v => v.MissionData)
|
.Include(v => v.MissionData)
|
||||||
.Include(v => v.Items)
|
.Include(v => v.Items).ThenInclude(i => i.Item)
|
||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
.FirstAsync(v => v.Id == viewerId);
|
.FirstAsync(v => v.Id == viewerId);
|
||||||
int packNumber = Math.Max(1, request.PackNumber);
|
int packNumber = Math.Max(1, request.PackNumber);
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ public class PackControllerTests
|
|||||||
await factory.SeedGlobalsAsync();
|
await factory.SeedGlobalsAsync();
|
||||||
long viewerId = await factory.SeedViewerAsync(tutorialState: 41);
|
long viewerId = await factory.SeedViewerAsync(tutorialState: 41);
|
||||||
|
|
||||||
|
// Seed the starter ticket the gift_receive step would have granted. /tutorial/pack_open
|
||||||
|
// is supposed to decrement this count by `pack_number` (1) and emit a post-state entry
|
||||||
|
// into reward_list (per project_wire_reward_list_post_state).
|
||||||
|
await factory.SeedOwnedItemAsync(viewerId, itemId: 90001, count: 1, itemName: "Starter Legendary Ticket");
|
||||||
|
|
||||||
// Pack 99047 (starter legendary) has base_pack_id=90001. The minimal card seed only
|
// Pack 99047 (starter legendary) has base_pack_id=90001. The minimal card seed only
|
||||||
// creates set 10001, so we seed set 90001 explicitly for the pool resolver.
|
// creates set 10001, so we seed set 90001 explicitly for the pool resolver.
|
||||||
using (var scope = factory.Services.CreateScope())
|
using (var scope = factory.Services.CreateScope())
|
||||||
@@ -80,6 +85,23 @@ public class PackControllerTests
|
|||||||
"Starter pack 99047/990047 delivers 8 cards (child_gacha.card_count=8).");
|
"Starter pack 99047/990047 delivers 8 cards (child_gacha.card_count=8).");
|
||||||
|
|
||||||
Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.EqualTo(100));
|
Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.EqualTo(100));
|
||||||
|
|
||||||
|
// Ticket decrement: the legendary starter ticket (90001) should be consumed.
|
||||||
|
Assert.That(await factory.GetOwnedItemCountAsync(viewerId, 90001), Is.EqualTo(0),
|
||||||
|
"Tutorial pack_open must decrement the gating ticket; otherwise /tutorial/pack_info " +
|
||||||
|
"keeps showing the pack and the client re-clicks into /pack/open (501 on type_detail=5).");
|
||||||
|
|
||||||
|
// reward_list must carry a post-state item entry for the ticket. RewardType=4 (Item),
|
||||||
|
// RewardId=90001, RewardNum=0 (post-state total, NOT delta).
|
||||||
|
var rewardList = root.GetProperty("reward_list");
|
||||||
|
var ticketEntry = rewardList.EnumerateArray()
|
||||||
|
.FirstOrDefault(e => e.GetProperty("reward_type").GetInt32() == 4
|
||||||
|
&& e.GetProperty("reward_id").GetInt64() == 90001);
|
||||||
|
Assert.That(ticketEntry.ValueKind, Is.Not.EqualTo(JsonValueKind.Undefined),
|
||||||
|
"reward_list must include a type=4 entry for the consumed ticket (90001) so the " +
|
||||||
|
"client's _userItemDict updates immediately — project_wire_reward_list_post_state.");
|
||||||
|
Assert.That(ticketEntry.GetProperty("reward_num").GetInt32(), Is.EqualTo(0),
|
||||||
|
"RewardNum is the post-state TOTAL, not the delta consumed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
|||||||
@@ -103,6 +103,11 @@ public class TutorialFlowEndToEndTests
|
|||||||
|
|
||||||
Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.EqualTo(100),
|
Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.EqualTo(100),
|
||||||
"Viewer reaches TUTORIAL_END after the full flow.");
|
"Viewer reaches TUTORIAL_END after the full flow.");
|
||||||
|
|
||||||
|
// The gift granted item 90001 count=1 (via /tutorial/gift_receive entry 71478630).
|
||||||
|
// /tutorial/pack_open consumes it; assert the ticket is gone post-flow.
|
||||||
|
Assert.That(await factory.GetOwnedItemCountAsync(viewerId, 90001), Is.EqualTo(0),
|
||||||
|
"Starter legendary ticket must be consumed by /tutorial/pack_open.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Task<HttpResponseMessage> Post(HttpClient client, string url, string body)
|
private static Task<HttpResponseMessage> Post(HttpClient client, string url, string body)
|
||||||
|
|||||||
@@ -397,6 +397,52 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
|
|||||||
return (viewer.Currency.Crystals, viewer.Currency.Rupees, viewer.Currency.RedEther);
|
return (viewer.Currency.Crystals, viewer.Currency.Rupees, viewer.Currency.RedEther);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds an OwnedItemEntry for the viewer. Inserts the ItemEntry master row if missing
|
||||||
|
/// (Type defaults to 2 = card-pack ticket since both tutorial gift items 80001 and 90001
|
||||||
|
/// are tickets). Tests use this to set up the ticket inventory that /tutorial/pack_open
|
||||||
|
/// is supposed to consume.
|
||||||
|
/// </summary>
|
||||||
|
public async Task SeedOwnedItemAsync(long viewerId, int itemId, int count, string itemName = "TestItem", int itemType = 2)
|
||||||
|
{
|
||||||
|
using var scope = Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
var item = await db.Items.FindAsync(itemId);
|
||||||
|
if (item is null)
|
||||||
|
{
|
||||||
|
item = new ItemEntry { Id = itemId, Name = itemName, Type = itemType };
|
||||||
|
db.Items.Add(item);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
var viewer = await db.Viewers
|
||||||
|
.Include(v => v.Items).ThenInclude(i => i.Item)
|
||||||
|
.FirstAsync(v => v.Id == viewerId);
|
||||||
|
var existing = viewer.Items.FirstOrDefault(i => i.Item.Id == itemId);
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
viewer.Items.Add(new OwnedItemEntry { Item = item, Count = count, Viewer = viewer });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
existing.Count = count;
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the viewer's current owned count for <paramref name="itemId"/>. Returns 0 if no
|
||||||
|
/// row exists. Tests use this to assert ticket consumption after /tutorial/pack_open.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<int> GetOwnedItemCountAsync(long viewerId, int itemId)
|
||||||
|
{
|
||||||
|
using var scope = Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
var viewer = await db.Viewers
|
||||||
|
.Include(v => v.Items).ThenInclude(i => i.Item)
|
||||||
|
.FirstAsync(v => v.Id == viewerId);
|
||||||
|
return viewer.Items.FirstOrDefault(i => i.Item.Id == itemId)?.Count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
protected override void Dispose(bool disposing)
|
protected override void Dispose(bool disposing)
|
||||||
{
|
{
|
||||||
base.Dispose(disposing);
|
base.Dispose(disposing);
|
||||||
|
|||||||
Reference in New Issue
Block a user