refactor(pack): route Open through InventoryService
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user