feat(pack): /pack/exchange_gacha_point endpoint
Wires IGachaPointService.TryExchangeAsync into a controller endpoint. Loads the viewer with the full cosmetic-grant graph (Cards, Sleeves, Emblems, Degrees, LeaderSkins, MyPageBackgrounds) plus the gacha-point balance + received marker, with AsSplitQuery to avoid the cartesian explosion documented in project_ef_split_query. Returns BadRequest with the outcome's error code on validation failure, or the post-state reward_list on success. Two integration tests: happy-path verifies card grant + balance debit + received-marker persistence + post-state reward_list shape; insufficient- balance path returns 400. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -163,6 +163,37 @@ public class PackController : SVSimController
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("exchange_gacha_point")]
|
||||
public async Task<ActionResult<ExchangeGachaPointResponse>> ExchangeGachaPoint(
|
||||
ExchangeGachaPointRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
// Load the viewer with the collections the service mutates (balances, received marker,
|
||||
// cards, cosmetics). AsSplitQuery per project_ef_split_query memory.
|
||||
var viewer = await _db.Viewers
|
||||
.Include(v => v.GachaPointBalances)
|
||||
.Include(v => v.GachaPointReceived)
|
||||
.Include(v => v.Cards)
|
||||
.Include(v => v.Sleeves)
|
||||
.Include(v => v.Emblems)
|
||||
.Include(v => v.Degrees)
|
||||
.Include(v => v.LeaderSkins)
|
||||
.Include(v => v.MyPageBackgrounds)
|
||||
.AsSplitQuery()
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
|
||||
var outcome = await _gachaPoint.TryExchangeAsync(viewer, request.ParentGachaId, request.CardId);
|
||||
if (!outcome.Success) return BadRequest(new { error = outcome.Error });
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new ExchangeGachaPointResponse
|
||||
{
|
||||
RewardList = outcome.RewardList.ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("open")]
|
||||
[HttpPost("/tutorial/pack_open")]
|
||||
public async Task<ActionResult<PackOpenResponse>> Open(PackOpenRequest request)
|
||||
|
||||
@@ -112,4 +112,112 @@ public class PackControllerGachaPointTests
|
||||
Assert.That(text, Does.Contain("\"is_received\":false"));
|
||||
Assert.That(text, Does.Contain("\"is_display_prize\":false"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ExchangeGachaPoint_grants_card_and_returns_post_state_reward_list()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
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 = 10008, IsInRotation = true };
|
||||
db.CardSets.Add(set);
|
||||
set.Cards.Add(new ShadowverseCardEntry
|
||||
{
|
||||
Id = 108041010, Name = "leg", Rarity = Rarity.Legendary,
|
||||
Class = db.Classes.Local.First(), IsFoil = false,
|
||||
});
|
||||
db.CardCosmeticRewards.Add(new CardCosmeticReward
|
||||
{
|
||||
CardId = 108041010, Type = CosmeticType.Emblem, CosmeticId = 1080410100,
|
||||
});
|
||||
db.Packs.Add(new PackConfigEntry
|
||||
{
|
||||
Id = 10008, BasePackId = 10008, PackCategory = PackCategory.LegendCardPack,
|
||||
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
|
||||
GachaPointConfig = new PackGachaPointConfig { ExchangeablePoint = 400, IncreaseGachaPoint = 1 },
|
||||
});
|
||||
var viewer = await db.Viewers
|
||||
.Include(v => v.GachaPointBalances)
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
viewer.GachaPointBalances.Add(new ViewerGachaPointBalance { PackId = 10008, Points = 500 });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var body = JsonBody("""{"card_id":108041010,"parent_gacha_id":10008,"odds_gacha_id":10008,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""");
|
||||
var response = await client.PostAsync("/pack/exchange_gacha_point", body);
|
||||
var text = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), text);
|
||||
|
||||
using var doc = JsonDocument.Parse(text);
|
||||
var rewardList = doc.RootElement.GetProperty("reward_list");
|
||||
Assert.That(rewardList.GetArrayLength(), Is.GreaterThan(0));
|
||||
// Verify the card grant entry (type=5/Card) is present with the granted card id.
|
||||
bool foundCard = false;
|
||||
foreach (var r in rewardList.EnumerateArray())
|
||||
{
|
||||
if (r.GetProperty("reward_type").GetInt32() == 5 &&
|
||||
r.GetProperty("reward_id").GetInt64() == 108041010)
|
||||
{
|
||||
foundCard = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Assert.That(foundCard, Is.True, "card grant entry missing from reward_list");
|
||||
|
||||
// Verify side-effects.
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var viewer = await db.Viewers
|
||||
.Include(v => v.GachaPointBalances)
|
||||
.Include(v => v.GachaPointReceived)
|
||||
.Include(v => v.Cards).ThenInclude(c => c.Card)
|
||||
.AsSplitQuery()
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
Assert.That(viewer.GachaPointBalances.Single().Points, Is.EqualTo(100));
|
||||
Assert.That(viewer.GachaPointReceived.Single().CardId, Is.EqualTo(108041010));
|
||||
Assert.That(viewer.Cards.Any(c => c.Card.Id == 108041010), Is.True);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ExchangeGachaPoint_rejects_when_balance_insufficient()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
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 = 10008, IsInRotation = true };
|
||||
db.CardSets.Add(set);
|
||||
set.Cards.Add(new ShadowverseCardEntry
|
||||
{
|
||||
Id = 108041010, Name = "leg", Rarity = Rarity.Legendary,
|
||||
Class = db.Classes.Local.First(), IsFoil = false,
|
||||
});
|
||||
db.CardCosmeticRewards.Add(new CardCosmeticReward
|
||||
{
|
||||
CardId = 108041010, Type = CosmeticType.Emblem, CosmeticId = 1080410100,
|
||||
});
|
||||
db.Packs.Add(new PackConfigEntry
|
||||
{
|
||||
Id = 10008, BasePackId = 10008, PackCategory = PackCategory.LegendCardPack,
|
||||
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
|
||||
GachaPointConfig = new PackGachaPointConfig { ExchangeablePoint = 400, IncreaseGachaPoint = 1 },
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var body = JsonBody("""{"card_id":108041010,"parent_gacha_id":10008,"odds_gacha_id":10008,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""");
|
||||
var response = await client.PostAsync("/pack/exchange_gacha_point", body);
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest),
|
||||
await response.Content.ReadAsStringAsync());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user