refactor(pack): route Open through InventoryService

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-31 16:20:23 -04:00
parent 57dd524d9f
commit 4d6da23443

View File

@@ -11,6 +11,7 @@ using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Pack; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Pack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Pack; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Pack;
using SVSim.Database.Services; using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Services; using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Controllers; namespace SVSim.EmulatedEntrypoint.Controllers;
@@ -30,10 +31,8 @@ public class PackController : SVSimController
private readonly ICardFoilLookup _foils; private readonly ICardFoilLookup _foils;
private readonly IRandom _rng; private readonly IRandom _rng;
private readonly SVSimDbContext _db; private readonly SVSimDbContext _db;
private readonly ICardAcquisitionService _acquisition; private readonly IInventoryService _inv;
private readonly IGachaPointService _gachaPoint; private readonly IGachaPointService _gachaPoint;
private readonly ICurrencySpendService _spend;
private readonly IViewerEntitlements _entitlements;
public PackController( public PackController(
IPackRepository packs, IPackRepository packs,
@@ -42,10 +41,8 @@ public class PackController : SVSimController
ICardFoilLookup foils, ICardFoilLookup foils,
IRandom rng, IRandom rng,
SVSimDbContext db, SVSimDbContext db,
ICardAcquisitionService acquisition, IInventoryService inv,
IGachaPointService gachaPoint, IGachaPointService gachaPoint)
ICurrencySpendService spend,
IViewerEntitlements entitlements)
{ {
_packs = packs; _packs = packs;
_opener = opener; _opener = opener;
@@ -53,10 +50,8 @@ public class PackController : SVSimController
_foils = foils; _foils = foils;
_rng = rng; _rng = rng;
_db = db; _db = db;
_acquisition = acquisition; _inv = inv;
_gachaPoint = gachaPoint; _gachaPoint = gachaPoint;
_spend = spend;
_entitlements = entitlements;
} }
[HttpPost("info")] [HttpPost("info")]
@@ -287,13 +282,12 @@ public class PackController : SVSimController
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))
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "currency_path_not_implemented" }); return StatusCode(StatusCodes.Status501NotImplemented, new { error = "currency_path_not_implemented" });
var viewer = await _db.Viewers // Load viewer via InventoryService transaction with extra includes for pack-open needs.
.Include(v => v.PackOpenCounts) await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted, cfg => cfg
.Include(v => v.GachaPointBalances) .WithInclude(v => v.PackOpenCounts)
.Include(v => v.MissionData) .WithInclude(v => v.GachaPointBalances)
.Include(v => v.Items).ThenInclude(i => i.Item) .WithInclude(v => v.MissionData));
.AsSplitQuery() var viewer = tx.Viewer;
.FirstAsync(v => v.Id == viewerId);
// 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
// completed the tutorial — re-running the path would re-consume the ticket they // completed the tutorial — re-running the path would re-consume the ticket they
@@ -314,7 +308,7 @@ public class PackController : SVSimController
case 2: // CRYSTAL_MULTI (10-pack) case 2: // CRYSTAL_MULTI (10-pack)
{ {
long cost = (long)child.Cost * packNumber; long cost = (long)child.Cost * packNumber;
var r = await _spend.TrySpendAsync(viewer, 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;
} }
@@ -322,7 +316,7 @@ public class PackController : SVSimController
case 7: // RUPY_MULTI (10-pack) case 7: // RUPY_MULTI (10-pack)
{ {
long cost = (long)child.Cost * packNumber; long cost = (long)child.Cost * packNumber;
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, cost); var r = await tx.TrySpendAsync(SpendCurrency.Rupee, cost);
if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
break; break;
} }
@@ -336,7 +330,7 @@ public class PackController : SVSimController
return BadRequest(new { error = "daily_free_already_claimed" }); return BadRequest(new { error = "daily_free_already_claimed" });
long cost = (long)child.Cost * packNumber; long cost = (long)child.Cost * packNumber;
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, cost); var r = await tx.TrySpendAsync(SpendCurrency.Rupee, cost);
if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
break; break;
} }
@@ -347,15 +341,11 @@ public class PackController : SVSimController
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "ticket_pack_missing_item_id" }); return StatusCode(StatusCodes.Status501NotImplemented, new { error = "ticket_pack_missing_item_id" });
int ticketsNeeded = child.Cost * packNumber; int ticketsNeeded = child.Cost * packNumber;
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)ticketItemId); var debit = await tx.TryDebitAsync(UserGoodsType.Item, ticketItemId, ticketsNeeded);
if (owned is null || owned.Count < ticketsNeeded) if (!debit.Success) return BadRequest(new { error = "insufficient_tickets" });
return BadRequest(new { error = "insufficient_tickets" });
owned.Count -= ticketsNeeded;
break; break;
} }
} }
await _db.SaveChangesAsync();
} }
// Increment open count + mark daily-free timestamp where relevant. // Increment open count + mark daily-free timestamp where relevant.
@@ -394,48 +384,17 @@ public class PackController : SVSimController
ownedCardIds, ownedCardIds,
_foils, _foils,
_rng); _rng);
var grant = await _acquisition.GrantManyAsync(viewerId, draw.Cards.Select(c => c.CardId));
// Grant drawn cards through the transaction — cosmetic cascade fires on first-time owners.
foreach (var grp in draw.Cards.GroupBy(c => c.CardId))
await tx.GrantAsync(UserGoodsType.Card, grp.Key, grp.Count());
// Accrue gacha points (skip tutorial path — the starter pack isn't a real open). // Accrue gacha points (skip tutorial path — the starter pack isn't a real open).
if (!isTutorialPath) if (!isTutorialPath)
{ {
_gachaPoint.Accrue(viewer, pack, child, drawCount); _gachaPoint.Accrue(viewer, pack, child, drawCount);
await _db.SaveChangesAsync();
} }
// Build reward_list. The service produces the type=5 (Card) entries with post-state counts
// plus any cosmetic grants. Currency entry (type=2 Crystals or type=9 Rupy) stays in the
// controller — it's a pack-purchase concern, not a card-grant concern. The client's
// PlayerStaticData.UpdateHaveUserGoodsNum does direct assignment, so currency/card counts
// must be the new TOTAL — emitting deltas would leave the on-screen balances stale.
var rewardList = new List<RewardListEntry>();
// Currency reward entries only apply to purchasable packs; tutorial path omits them.
if (!isTutorialPath)
{
if (child.TypeDetail is 1 or 2)
{
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)_entitlements.EffectiveBalance(viewer, SpendCurrency.Crystal) });
}
else if (child.TypeDetail is 3 or 6 or 7)
{
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)_entitlements.EffectiveBalance(viewer, SpendCurrency.Rupee) });
}
else if (child.TypeDetail is 4 or 5 && child.ItemId is long ticketItemId)
{
// Item post-state count for the ticket we just consumed — client direct-assigns
// _userItemDict, so this must be the new total (project_wire_reward_list_post_state).
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)ticketItemId);
rewardList.Add(new RewardListEntry
{
RewardType = 4, // Item
RewardId = ticketItemId,
RewardNum = owned?.Count ?? 0, // post-state total
});
}
}
rewardList.AddRange(grant.RewardList);
// Tutorial path consumes the granted ticket (same item_id used to gate display) so the // Tutorial path consumes the granted ticket (same item_id used to gate display) so the
// pack drops out of /tutorial/pack_info on next refresh. Without this, the pack still // pack drops out of /tutorial/pack_info on next refresh. Without this, the pack still
// shows item_number=1 after the tutorial pack-open, the client lets the user re-click // shows item_number=1 after the tutorial pack-open, the client lets the user re-click
@@ -447,19 +406,12 @@ public class PackController : SVSimController
int? responseTutorialStep = null; int? responseTutorialStep = null;
if (isTutorialPath) if (isTutorialPath)
{ {
if (child.ItemId is long ticketItemId) if (child.ItemId is long tutorialTicketItemId)
{ {
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)ticketItemId); int ticketsToConsume = packNumber;
if (owned is not null) var debit = await tx.TryDebitAsync(UserGoodsType.Item, tutorialTicketItemId, ticketsToConsume);
{ // Silently accept if the viewer doesn't have the ticket (already consumed or never granted)
owned.Count = Math.Max(0, owned.Count - packNumber); _ = debit;
rewardList.Add(new RewardListEntry
{
RewardType = 4, // Item
RewardId = ticketItemId,
RewardNum = owned.Count, // POST-STATE total
});
}
} }
// Max-preserve: never regress the persisted state, even though Gate B already // Max-preserve: never regress the persisted state, even though Gate B already
@@ -468,10 +420,16 @@ public class PackController : SVSimController
// the tutorial-END signal the client expects. // 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;
await _db.SaveChangesAsync();
responseTutorialStep = TutorialEndStep; responseTutorialStep = TutorialEndStep;
} }
// 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 rewardList = result.RewardList
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
.ToList();
return new PackOpenResponse return new PackOpenResponse
{ {
PackList = draw.Cards.Select(c => new CardPackEntryDto PackList = draw.Cards.Select(c => new CardPackEntryDto