Additional card content
This commit is contained in:
@@ -117,6 +117,18 @@ public class BaseDataSeeder : IDataSeeder
|
||||
}
|
||||
}
|
||||
|
||||
private class CardCosmeticRewardMap : ClassMap<CardCosmeticReward>
|
||||
{
|
||||
public CardCosmeticRewardMap()
|
||||
{
|
||||
Map(m => m.CardId).Name("card_id");
|
||||
Map(m => m.Type).Name("type");
|
||||
Map(m => m.CosmeticId).Name("cosmetic_id");
|
||||
Map(m => m.Quantity).Name("quantity").Default(1);
|
||||
Map(m => m.Card).Ignore();
|
||||
}
|
||||
}
|
||||
|
||||
public void Seed(ModelBuilder builder)
|
||||
{
|
||||
// Migrations bake the HasData rows into InsertData calls — once the migration is
|
||||
@@ -146,6 +158,7 @@ public class BaseDataSeeder : IDataSeeder
|
||||
List<MyPageBackgroundEntry> myPageBackgrounds = ReadCsv<MyPageBackgroundEntry, MyPageBackgroundEntryMap>("mypagebackgrounds.csv");
|
||||
List<RankInfoEntry> rankinfos = ReadCsv<RankInfoEntry, RankInfoEntryMap>("ranks.csv");
|
||||
List<ClassExpEntry> classexp = ReadCsv<ClassExpEntry, ClassExpEntryMap>("classexp.csv");
|
||||
List<CardCosmeticReward> cardCosmeticRewards = ReadCsv<CardCosmeticReward, CardCosmeticRewardMap>("card_cosmetic_rewards.csv");
|
||||
|
||||
builder.Entity<ClassEntry>().HasData(classes);
|
||||
builder.Entity<LeaderSkinEntry>().HasData(leaderSkins);
|
||||
@@ -156,5 +169,6 @@ public class BaseDataSeeder : IDataSeeder
|
||||
builder.Entity<MyPageBackgroundEntry>().HasData(myPageBackgrounds);
|
||||
builder.Entity<RankInfoEntry>().HasData(rankinfos);
|
||||
builder.Entity<ClassExpEntry>().HasData(classexp);
|
||||
builder.Entity<CardCosmeticReward>().HasData(cardCosmeticRewards);
|
||||
}
|
||||
}
|
||||
|
||||
15
SVSim.Database/Enums/CosmeticType.cs
Normal file
15
SVSim.Database/Enums/CosmeticType.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace SVSim.Database.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Subset of UserGoods.Type values that can be granted as a card-acquisition cosmetic.
|
||||
/// Numeric values MUST match Wizard/UserGoods.cs:8-22 so wire serialization
|
||||
/// (reward_type in /pack/open response) is direct passthrough.
|
||||
/// </summary>
|
||||
public enum CosmeticType
|
||||
{
|
||||
Sleeve = 6,
|
||||
Emblem = 7,
|
||||
Degree = 8,
|
||||
Skin = 10,
|
||||
MyPageBG = 15,
|
||||
}
|
||||
42477
SVSim.Database/Migrations/20260524193503_AddCardCosmeticRewards.Designer.cs
generated
Normal file
42477
SVSim.Database/Migrations/20260524193503_AddCardCosmeticRewards.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
1123
SVSim.Database/Migrations/20260524193503_AddCardCosmeticRewards.cs
Normal file
1123
SVSim.Database/Migrations/20260524193503_AddCardCosmeticRewards.cs
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
24
SVSim.Database/Models/CardCosmeticReward.cs
Normal file
24
SVSim.Database/Models/CardCosmeticReward.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using SVSim.Database.Enums;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Association: when a viewer acquires <see cref="CardId"/>, they should also receive
|
||||
/// the cosmetic identified by (<see cref="Type"/>, <see cref="CosmeticId"/>) if they don't
|
||||
/// already own it.
|
||||
///
|
||||
/// Always recorded on the NON-FOIL row of a card. Foil twins (card_id + 1) inherit at
|
||||
/// lookup time — see CardAcquisitionService for the foil-resolution rule.
|
||||
///
|
||||
/// Composite PK on (CardId, Type, CosmeticId): naturally enforces "no duplicates" AND
|
||||
/// satisfies EF's deterministic-PK requirement for HasData seeding.
|
||||
/// </summary>
|
||||
public class CardCosmeticReward
|
||||
{
|
||||
public long CardId { get; set; }
|
||||
public CosmeticType Type { get; set; }
|
||||
public long CosmeticId { get; set; }
|
||||
public int Quantity { get; set; } = 1;
|
||||
|
||||
public ShadowverseCardEntry Card { get; set; } = null!;
|
||||
}
|
||||
@@ -25,6 +25,7 @@ public class SVSimDbContext : DbContext
|
||||
public DbSet<ShadowverseCardEntry> Cards => Set<ShadowverseCardEntry>();
|
||||
public DbSet<ShadowverseCardSetEntry> CardSets => Set<ShadowverseCardSetEntry>();
|
||||
public DbSet<ShadowverseDeckEntry> Decks => Set<ShadowverseDeckEntry>();
|
||||
public DbSet<CardCosmeticReward> CardCosmeticRewards => Set<CardCosmeticReward>();
|
||||
|
||||
public DbSet<ClassEntry> Classes => Set<ClassEntry>();
|
||||
public DbSet<ClassExpEntry> ClassExpCurve => Set<ClassExpEntry>();
|
||||
@@ -115,6 +116,18 @@ public class SVSimDbContext : DbContext
|
||||
modelBuilder.Entity<PackConfigEntry>().OwnsMany(p => p.Banners);
|
||||
modelBuilder.Entity<Viewer>().OwnsMany(v => v.PackOpenCounts);
|
||||
|
||||
modelBuilder.Entity<CardCosmeticReward>(b =>
|
||||
{
|
||||
b.HasKey(r => new { r.CardId, r.Type, r.CosmeticId });
|
||||
b.HasIndex(r => r.CardId);
|
||||
// No inverse nav on the Card side — avoid forcing CosmeticRewards to load on every
|
||||
// Card query. See project_ef_split_query memory for the cartesian-explode risk.
|
||||
b.HasOne(r => r.Card)
|
||||
.WithMany()
|
||||
.HasForeignKey(r => r.CardId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// GameConfiguration.Config: on Postgres use EF Core 8's OwnsOne+ToJson(jsonb column).
|
||||
// On SQLite (tests) ToJson's WriteJson has a known NullReferenceException when owned
|
||||
// collections are present — use a plain TEXT value converter instead so the same
|
||||
|
||||
@@ -13,6 +13,7 @@ using SVSim.EmulatedEntrypoint.Infrastructure;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
@@ -41,14 +42,17 @@ public class LoadController : SVSimController
|
||||
private readonly ICardRepository _cardRepository;
|
||||
private readonly ICollectionRepository _collectionRepository;
|
||||
private readonly IGlobalsRepository _globalsRepository;
|
||||
private readonly ICardAcquisitionService _acquisition;
|
||||
|
||||
public LoadController(IViewerRepository viewerRepository, ICardRepository cardRepository,
|
||||
ICollectionRepository collectionRepository, IGlobalsRepository globalsRepository)
|
||||
ICollectionRepository collectionRepository, IGlobalsRepository globalsRepository,
|
||||
ICardAcquisitionService acquisition)
|
||||
{
|
||||
_viewerRepository = viewerRepository;
|
||||
_cardRepository = cardRepository;
|
||||
_collectionRepository = collectionRepository;
|
||||
_globalsRepository = globalsRepository;
|
||||
_acquisition = acquisition;
|
||||
}
|
||||
|
||||
[HttpPost("index")]
|
||||
@@ -66,6 +70,18 @@ public class LoadController : SVSimController
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Backfill any card-associated cosmetics the viewer should already own. Idempotent.
|
||||
// We MUST re-fetch the viewer after this call because GetViewerByShortUdid uses
|
||||
// .AsNoTracking() — the local `viewer` instance is detached, and the service's writes
|
||||
// (on a separate tracked instance) won't appear on this snapshot. Without the re-fetch,
|
||||
// the response payload would be one /load/index behind on newly-granted cosmetics.
|
||||
await _acquisition.GrantAsync(viewer.Id, newCardIds: null);
|
||||
viewer = await _viewerRepository.GetViewerByShortUdid(shortUdid);
|
||||
if (viewer is null)
|
||||
{
|
||||
return NotFound(); // defensive — should never happen
|
||||
}
|
||||
|
||||
// user_card_list policy (see docs/api-spec/endpoints/post-login/load-index.md
|
||||
// §user_card_list for the full discussion):
|
||||
//
|
||||
|
||||
@@ -27,19 +27,22 @@ public class PackController : SVSimController
|
||||
private readonly ICardPoolProvider _pools;
|
||||
private readonly IRandom _rng;
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly ICardAcquisitionService _acquisition;
|
||||
|
||||
public PackController(
|
||||
IPackRepository packs,
|
||||
PackOpenService opener,
|
||||
ICardPoolProvider pools,
|
||||
IRandom rng,
|
||||
SVSimDbContext db)
|
||||
SVSimDbContext db,
|
||||
ICardAcquisitionService acquisition)
|
||||
{
|
||||
_packs = packs;
|
||||
_opener = opener;
|
||||
_pools = pools;
|
||||
_rng = rng;
|
||||
_db = db;
|
||||
_acquisition = acquisition;
|
||||
}
|
||||
|
||||
[HttpPost("info")]
|
||||
@@ -193,16 +196,15 @@ public class PackController : SVSimController
|
||||
// Draw + persist. DAILY single overrides packNumber to 1 (it's a one-card open).
|
||||
int drawCount = child.IsDailySingle ? 1 : packNumber;
|
||||
var draw = _opener.Draw(pack, _pools, drawCount, request.ExcludeCardIds, _rng);
|
||||
await _packs.GrantCardsToViewer(viewerId, draw.Cards.Select(c => c.CardId));
|
||||
var grant = await _acquisition.GrantAsync(viewerId, draw.Cards.Select(c => c.CardId));
|
||||
|
||||
// Build reward_list with post-state totals. The client's PlayerStaticData.UpdateHaveUserGoodsNum
|
||||
// does direct assignment (`UserRupyCount = reward_num`, owned-count = reward_num), so we
|
||||
// emit the new totals — not deltas. Without these the on-screen rupee/crystal/collection
|
||||
// counts stay stale until the next /mypage/refresh or restart.
|
||||
// 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>();
|
||||
var postViewer = await _db.Viewers
|
||||
.Include(v => v.Cards).ThenInclude(c => c.Card)
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
var postViewer = await _db.Viewers.FirstAsync(v => v.Id == viewerId);
|
||||
|
||||
if (child.TypeDetail == 2)
|
||||
{
|
||||
@@ -212,12 +214,7 @@ public class PackController : SVSimController
|
||||
{
|
||||
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)postViewer.Currency.Rupees });
|
||||
}
|
||||
|
||||
var drawnCardIds = draw.Cards.Select(c => c.CardId).Distinct().ToHashSet();
|
||||
foreach (var owned in postViewer.Cards.Where(c => drawnCardIds.Contains(c.Card.Id)))
|
||||
{
|
||||
rewardList.Add(new RewardListEntry { RewardType = 5, RewardId = owned.Card.Id, RewardNum = owned.Count });
|
||||
}
|
||||
rewardList.AddRange(grant.RewardList);
|
||||
|
||||
return new PackOpenResponse
|
||||
{
|
||||
|
||||
1069
SVSim.EmulatedEntrypoint/Data/card_cosmetic_rewards.csv
Normal file
1069
SVSim.EmulatedEntrypoint/Data/card_cosmetic_rewards.csv
Normal file
File diff suppressed because it is too large
Load Diff
@@ -72,6 +72,7 @@ public class Program
|
||||
.GetGameConfiguration("default").GetAwaiter().GetResult().Config);
|
||||
builder.Services.AddScoped<ICardPoolProvider, DbCardPoolProvider>();
|
||||
builder.Services.AddScoped<PackOpenService>();
|
||||
builder.Services.AddScoped<ICardAcquisitionService, CardAcquisitionService>();
|
||||
builder.Services.AddSingleton<IRandom, SystemRandom>();
|
||||
|
||||
#endregion
|
||||
|
||||
149
SVSim.EmulatedEntrypoint/Services/CardAcquisitionService.cs
Normal file
149
SVSim.EmulatedEntrypoint/Services/CardAcquisitionService.cs
Normal file
@@ -0,0 +1,149 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.Pack;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
public class CardAcquisitionService : ICardAcquisitionService
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly IPackRepository _packs;
|
||||
private readonly ILogger<CardAcquisitionService> _log;
|
||||
|
||||
public CardAcquisitionService(SVSimDbContext db, IPackRepository packs, ILogger<CardAcquisitionService> log)
|
||||
{
|
||||
_db = db; _packs = packs; _log = log;
|
||||
}
|
||||
|
||||
public async Task<CardGrantResult> GrantAsync(long viewerId, IEnumerable<long>? newCardIds = null)
|
||||
{
|
||||
var rewardList = new List<RewardListEntry>();
|
||||
|
||||
var viewer = await _db.Viewers
|
||||
.Include(v => v.Cards).ThenInclude(c => c.Card)
|
||||
.Include(v => v.LeaderSkins)
|
||||
.Include(v => v.Sleeves)
|
||||
.Include(v => v.Emblems)
|
||||
.Include(v => v.Degrees)
|
||||
.Include(v => v.MyPageBackgrounds)
|
||||
.AsSplitQuery()
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
|
||||
List<long> lookupSourceCardIds;
|
||||
if (newCardIds is not null)
|
||||
{
|
||||
var newIds = newCardIds.ToList();
|
||||
await _packs.GrantCardsToViewer(viewerId, newIds);
|
||||
// GrantCardsToViewer mutates OwnedCardEntry rows on the same scoped SVSimDbContext
|
||||
// AND commits them via its own SaveChangesAsync. The change tracker already exposes
|
||||
// the post-state via viewer.Cards. Note this means GrantAsync performs two saves total
|
||||
// (one inside the repo call, one at the end for cosmetic grants) — accepted because
|
||||
// any inconsistency in the failure window is self-healing via the next /load/index
|
||||
// backfill.
|
||||
|
||||
lookupSourceCardIds = newIds.Distinct().ToList();
|
||||
|
||||
foreach (var distinctId in lookupSourceCardIds)
|
||||
{
|
||||
var owned = viewer.Cards.First(c => c.Card.Id == distinctId);
|
||||
rewardList.Add(new RewardListEntry { RewardType = 5, RewardId = distinctId, RewardNum = owned.Count });
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Backfill mode: scan all owned cards for missing cosmetics. No card-count mutation.
|
||||
lookupSourceCardIds = viewer.Cards.Select(c => c.Card.Id).Distinct().ToList();
|
||||
}
|
||||
|
||||
// Foil resolution: cosmetic mappings are recorded on the non-foil card row.
|
||||
// Foil twins (card_id + 1) inherit via the universal +1 convention.
|
||||
var lookupCardIds = lookupSourceCardIds
|
||||
.Select(id =>
|
||||
{
|
||||
var card = viewer.Cards.FirstOrDefault(c => c.Card.Id == id)?.Card;
|
||||
return (card?.IsFoil == true) ? id - 1 : id;
|
||||
})
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var rewards = await _db.CardCosmeticRewards
|
||||
.Where(r => lookupCardIds.Contains(r.CardId))
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var reward in rewards)
|
||||
{
|
||||
if (await TryGrant(viewer, reward))
|
||||
{
|
||||
rewardList.Add(new RewardListEntry
|
||||
{
|
||||
RewardType = (int)reward.Type,
|
||||
RewardId = reward.CosmeticId,
|
||||
RewardNum = reward.Quantity,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return new CardGrantResult(rewardList);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the cosmetic was newly granted (caller should emit a reward_list entry).
|
||||
/// Returns false if the viewer already owned it or the master row is missing (defensive log).
|
||||
/// </summary>
|
||||
private async Task<bool> TryGrant(Viewer viewer, CardCosmeticReward reward)
|
||||
{
|
||||
var id = (int)reward.CosmeticId; // master tables use int Id
|
||||
|
||||
switch (reward.Type)
|
||||
{
|
||||
case CosmeticType.Skin:
|
||||
{
|
||||
if (viewer.LeaderSkins.Any(s => s.Id == id)) return false;
|
||||
var master = await _db.LeaderSkins.FindAsync(id);
|
||||
if (master is null) { _log.LogWarning("Skin master row missing for cosmetic_id={Id}", id); return false; }
|
||||
viewer.LeaderSkins.Add(master);
|
||||
return true;
|
||||
}
|
||||
case CosmeticType.Sleeve:
|
||||
{
|
||||
if (viewer.Sleeves.Any(s => s.Id == id)) return false;
|
||||
var master = await _db.Sleeves.FindAsync(id);
|
||||
if (master is null) { _log.LogWarning("Sleeve master row missing for cosmetic_id={Id}", id); return false; }
|
||||
viewer.Sleeves.Add(master);
|
||||
return true;
|
||||
}
|
||||
case CosmeticType.Emblem:
|
||||
{
|
||||
if (viewer.Emblems.Any(e => e.Id == id)) return false;
|
||||
var master = await _db.Emblems.FindAsync(id);
|
||||
if (master is null) { _log.LogWarning("Emblem master row missing for cosmetic_id={Id}", id); return false; }
|
||||
viewer.Emblems.Add(master);
|
||||
return true;
|
||||
}
|
||||
case CosmeticType.Degree:
|
||||
{
|
||||
if (viewer.Degrees.Any(d => d.Id == id)) return false;
|
||||
var master = await _db.Degrees.FindAsync(id);
|
||||
if (master is null) { _log.LogWarning("Degree master row missing for cosmetic_id={Id}", id); return false; }
|
||||
viewer.Degrees.Add(master);
|
||||
return true;
|
||||
}
|
||||
case CosmeticType.MyPageBG:
|
||||
{
|
||||
if (viewer.MyPageBackgrounds.Any(b => b.Id == id)) return false;
|
||||
var master = await _db.MyPageBackgrounds.FindAsync(id);
|
||||
if (master is null) { _log.LogWarning("MyPageBG master row missing for cosmetic_id={Id}", id); return false; }
|
||||
viewer.MyPageBackgrounds.Add(master);
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
_log.LogWarning("Unknown CosmeticType {Type} for card {CardId}", reward.Type, reward.CardId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
13
SVSim.EmulatedEntrypoint/Services/CardGrantResult.cs
Normal file
13
SVSim.EmulatedEntrypoint/Services/CardGrantResult.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Output of <see cref="ICardAcquisitionService.GrantAsync"/>. The RewardList is wire-shape:
|
||||
/// pass directly into a /pack/open or similar response's <c>data.reward_list</c> field.
|
||||
///
|
||||
/// In grant mode, contains one type=5 (Card) entry per distinct newCardId with post-state
|
||||
/// count, plus one entry per newly-granted cosmetic.
|
||||
/// In backfill mode, contains only cosmetic entries (no card-count entries).
|
||||
/// </summary>
|
||||
public record CardGrantResult(IReadOnlyList<RewardListEntry> RewardList);
|
||||
18
SVSim.EmulatedEntrypoint/Services/ICardAcquisitionService.cs
Normal file
18
SVSim.EmulatedEntrypoint/Services/ICardAcquisitionService.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
public interface ICardAcquisitionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Grant cards + associated cosmetics in one transaction.
|
||||
///
|
||||
/// • <paramref name="newCardIds"/> non-null → increments OwnedCardEntry for each via
|
||||
/// the existing IPackRepository.GrantCardsToViewer primitive, then grants any
|
||||
/// cosmetics associated with those cards that the viewer doesn't yet own.
|
||||
/// • <paramref name="newCardIds"/> null → backfill mode: skips card mutation,
|
||||
/// scans viewer.Cards, grants missing cosmetics.
|
||||
///
|
||||
/// Returns wire-shape RewardList in both modes. Backfill callers typically discard.
|
||||
/// All ownership writes happen in a single SaveChangesAsync.
|
||||
/// </summary>
|
||||
Task<CardGrantResult> GrantAsync(long viewerId, IEnumerable<long>? newCardIds = null);
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Controllers;
|
||||
@@ -295,4 +300,66 @@ public class LoadControllerTests
|
||||
Assert.That(root.TryGetProperty("battle_pass_level_info", out _), Is.False,
|
||||
"battle_pass_level_info optional per spec; emit null until viewer pass state is wired");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task LoadIndex_GrantsMissingCosmeticsForOwnedCards()
|
||||
{
|
||||
// Verifies the C.2 wiring: /load/index invokes ICardAcquisitionService in backfill mode,
|
||||
// so a viewer who already owns a leader card but lacks its associated cosmetics gets
|
||||
// them granted on the next load. Mirrors the in-flight migration story for existing
|
||||
// accounts that pre-date the cosmetic-grant feature.
|
||||
using var factory = new SVSimTestFactory();
|
||||
var viewerId = await factory.SeedViewerAsync();
|
||||
|
||||
// Seed viewer with leader card 704741010 (count=1), seed mapping → skin 407, seed
|
||||
// master skin row. Viewer does NOT yet own the skin.
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var viewer = await db.Viewers.Include(v => v.Cards).FirstAsync(v => v.Id == viewerId);
|
||||
var card = await db.Cards.FindAsync(704741010L);
|
||||
if (card is null)
|
||||
{
|
||||
card = new ShadowverseCardEntry { Id = 704741010L, Name = "TestLeader", Rarity = Rarity.Legendary, IsFoil = false };
|
||||
db.Cards.Add(card);
|
||||
}
|
||||
viewer.Cards.Add(new OwnedCardEntry { Card = card, Count = 1, IsProtected = false });
|
||||
db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 });
|
||||
if (await db.LeaderSkins.FindAsync(407) is null)
|
||||
db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Call /load/index — backfill should fire as part of the action.
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var response = await client.PostAsync("/load/index",
|
||||
new StringContent(IndexRequestJson, Encoding.UTF8, "application/json"));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
|
||||
await response.Content.ReadAsStringAsync());
|
||||
|
||||
// Verify the response payload includes the backfilled cosmetic (not just the DB state).
|
||||
// This guards against a regression where the controller serves a stale viewer snapshot
|
||||
// (GetViewerByShortUdid uses .AsNoTracking() so the in-memory `viewer` reference does
|
||||
// not see writes the service makes on its own tracked instance — without a post-grant
|
||||
// re-fetch the first /load/index would report the skin as un-owned even though the DB
|
||||
// had been updated). user_leader_skin_list always carries all master skins; the per-entry
|
||||
// is_owned flag is the actual ownership signal.
|
||||
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
var skin407 = doc.RootElement.GetProperty("user_leader_skin_list").EnumerateArray()
|
||||
.FirstOrDefault(e => e.GetProperty("leader_skin_id").GetInt32() == 407);
|
||||
Assert.That(skin407.ValueKind, Is.Not.EqualTo(JsonValueKind.Undefined),
|
||||
"response payload should include leader skin 407 entry");
|
||||
Assert.That(skin407.GetProperty("is_owned").GetBoolean(), Is.True,
|
||||
"response payload should mark backfilled skin 407 as owned, not just DB state");
|
||||
|
||||
// Verify skin 407 was actually granted by re-reading viewer state.
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var viewer = await db.Viewers.Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId);
|
||||
Assert.That(viewer.LeaderSkins.Any(s => s.Id == 407), Is.True,
|
||||
"skin 407 should have been backfilled by /load/index");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,4 +354,114 @@ public class PackControllerOpenTests
|
||||
Assert.That(cardEntryIds, Is.SupersetOf(drawnCardIds),
|
||||
"Every unique drawn card_id must appear in reward_list with reward_type=5.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task OpenPack_ResponseIncludesSkinEntry_WhenLeaderCardDrawn()
|
||||
{
|
||||
// Verifies the C.1 refactor surfaces cosmetic grants from ICardAcquisitionService into
|
||||
// the /pack/open response. Seeds a pack whose pool contains ONLY a known leader card
|
||||
// (so every slot is forced to that card via PickCardOfRarity's fallback walk), plus the
|
||||
// CardCosmeticReward mapping + LeaderSkinEntry master row. Expectation: response's
|
||||
// reward_list contains a type=10 (Skin) entry with the mapped skin_id.
|
||||
const long LeaderCardId = 704741010L;
|
||||
const int SkinId = 407;
|
||||
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
int parentGachaId = await SeedSingleLeaderCardPack(factory, LeaderCardId);
|
||||
await SeedCosmeticMapping(factory, LeaderCardId, SkinId);
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
|
||||
v.Currency.Rupees = 200;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var json = $$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":{{parentGachaId}},"gacha_id":{{parentGachaId * 100 + 2}},"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);
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var rewardList = doc.RootElement.GetProperty("reward_list");
|
||||
var skinEntry = Enumerable.Range(0, rewardList.GetArrayLength())
|
||||
.Select(i => rewardList[i])
|
||||
.FirstOrDefault(e => e.GetProperty("reward_type").GetInt32() == 10);
|
||||
|
||||
Assert.That(skinEntry.ValueKind, Is.Not.EqualTo(JsonValueKind.Undefined),
|
||||
"expected a Skin (type=10) entry in /pack/open's reward_list when a leader card is drawn");
|
||||
Assert.That(skinEntry.GetProperty("reward_id").GetInt64(), Is.EqualTo((long)SkinId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new CardSet whose ONLY non-foil card is <paramref name="leaderCardId"/>, plus
|
||||
/// a PackConfigEntry pointing at that set. PackOpenService's PickCardOfRarity fallback walks
|
||||
/// every rarity and lands on the sole available card, so every draw from this pack is
|
||||
/// guaranteed to produce <paramref name="leaderCardId"/>. Returns the parent gacha id.
|
||||
/// </summary>
|
||||
private static async Task<int> SeedSingleLeaderCardPack(SVSimTestFactory f, long leaderCardId)
|
||||
{
|
||||
const int CardSetId = 99001;
|
||||
const int ParentGachaId = 99001;
|
||||
|
||||
using var scope = f.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
// Reuse an existing card row if one exists at this id (e.g. seeded by globals); else
|
||||
// insert a Legendary stub. Rarity=Legendary so SV Classic slot-8 (which forbids Bronze)
|
||||
// resolves cleanly without falling through.
|
||||
var card = await db.Cards.FirstOrDefaultAsync(c => c.Id == leaderCardId);
|
||||
if (card is null)
|
||||
{
|
||||
card = new ShadowverseCardEntry { Id = leaderCardId, Name = $"SeededLeader{leaderCardId}", Rarity = Rarity.Legendary };
|
||||
db.Cards.Add(card);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Attach to a dedicated CardSet so the pool resolver (PackCategory.None branch) sees
|
||||
// exactly this one card.
|
||||
var set = new ShadowverseCardSetEntry
|
||||
{
|
||||
Id = CardSetId, Name = "SingleLeaderTestSet",
|
||||
IsInRotation = false, IsBasic = false,
|
||||
Cards = new List<ShadowverseCardEntry> { card },
|
||||
};
|
||||
db.CardSets.Add(set);
|
||||
|
||||
db.Packs.Add(new PackConfigEntry
|
||||
{
|
||||
Id = ParentGachaId, BasePackId = CardSetId, PackCategory = PackCategory.None,
|
||||
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
|
||||
GachaType = 1, GachaDetail = "single-leader test", SleeveId = 3000099,
|
||||
ChildGachas = {
|
||||
new PackChildGachaEntry { GachaId = ParentGachaId * 100 + 2, TypeDetail = 7, Cost = 100, CardCount = 8 },
|
||||
},
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return ParentGachaId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inserts a CardCosmeticReward(Skin) row mapping <paramref name="cardId"/> → <paramref name="skinId"/>,
|
||||
/// and ensures the LeaderSkinEntry master row exists. Production seed for CardCosmeticReward
|
||||
/// is stripped in tests by SqliteFriendlyModelCustomizer, so the test must insert its own.
|
||||
/// </summary>
|
||||
private static async Task SeedCosmeticMapping(SVSimTestFactory f, long cardId, int skinId)
|
||||
{
|
||||
using var scope = f.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
if (await db.LeaderSkins.FindAsync(skinId) is null)
|
||||
db.LeaderSkins.Add(new LeaderSkinEntry { Id = skinId, Name = $"TestSkin{skinId}" });
|
||||
|
||||
db.CardCosmeticRewards.Add(new CardCosmeticReward
|
||||
{
|
||||
CardId = cardId, Type = CosmeticType.Skin, CosmeticId = skinId, Quantity = 1,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,34 @@ internal class SqliteFriendlyModelCustomizer : ModelCustomizer
|
||||
shortUdidProperty.ValueGenerated = ValueGenerated.Never;
|
||||
|
||||
AssignClientSideKeyGenerators(modelBuilder.Model);
|
||||
StripCardCosmeticRewardSeed(modelBuilder.Model);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CardCosmeticReward rows have an FK to Cards.Id, and the production model HasData-seeds
|
||||
/// 1068 rows from card_cosmetic_rewards.csv (chunk A.2). In production those rows have
|
||||
/// matching cards inserted by the CardImporter before runtime. The unit-test factory uses
|
||||
/// SQLite + EnsureCreated + a minimal 3-card seed — most of the cosmetic-reward rows have
|
||||
/// no matching Cards row, and EnsureCreated's FK-deferred batch insert throws SqliteException
|
||||
/// "FOREIGN KEY constraint failed" at host construction time. Strip the seed in tests; the
|
||||
/// test fixture inserts CardCosmeticReward rows ad-hoc when a specific scenario needs them.
|
||||
///
|
||||
/// HasData seed is stored in the internal EntityType._data field (no public API). Clear it
|
||||
/// via reflection. The clear runs after base.Customize so the HasData call inside Seed()
|
||||
/// has populated the list before we wipe it.
|
||||
/// </summary>
|
||||
private static void StripCardCosmeticRewardSeed(IMutableModel model)
|
||||
{
|
||||
var entityType = model.FindEntityType(typeof(CardCosmeticReward));
|
||||
if (entityType is null) return;
|
||||
|
||||
// EntityType._data is a List<IDictionary<string, object>>? — null when empty.
|
||||
var dataField = entityType.GetType().GetField(
|
||||
"_data",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
if (dataField is null) return;
|
||||
var list = dataField.GetValue(entityType) as System.Collections.IList;
|
||||
list?.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
365
SVSim.UnitTests/Services/CardAcquisitionServiceTests.cs
Normal file
365
SVSim.UnitTests/Services/CardAcquisitionServiceTests.cs
Normal file
@@ -0,0 +1,365 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Services;
|
||||
|
||||
public class CardAcquisitionServiceTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Seeds a viewer (via the factory's real RegisterViewer-backed helper) and gives it the
|
||||
/// given owned cards (key = card_id, value = count). Card rows are created on-demand if
|
||||
/// the test's card_id isn't already in the minimal seeded card set (matches the pattern
|
||||
/// used by SVSimTestFactory.SeedOwnedCardAsync, but inlined so multiple cards can be
|
||||
/// seeded in one viewer in one call). Returns the viewer's Id.
|
||||
/// </summary>
|
||||
private static async Task<long> SeedViewerWithCards(
|
||||
SVSimTestFactory factory,
|
||||
Dictionary<long, int> ownedCards,
|
||||
IEnumerable<long>? grantableCardIds = null)
|
||||
{
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card)
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
|
||||
foreach (var (cardId, count) in ownedCards)
|
||||
{
|
||||
var card = await EnsureCardAsync(db, cardId);
|
||||
viewer.Cards.Add(new OwnedCardEntry { Card = card, Count = count, IsProtected = false });
|
||||
}
|
||||
// Pre-seed bare Cards rows (no ownership) for any cardIds the test plans to grant via
|
||||
// the service. GrantCardsToViewer does a FirstAsync(c => c.Id == grpKey) lookup; without
|
||||
// these the production code throws "Sequence contains no elements".
|
||||
if (grantableCardIds is not null)
|
||||
{
|
||||
foreach (var cardId in grantableCardIds)
|
||||
{
|
||||
await EnsureCardAsync(db, cardId);
|
||||
}
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return viewerId;
|
||||
}
|
||||
|
||||
private static async Task<ShadowverseCardEntry> EnsureCardAsync(SVSimDbContext db, long cardId)
|
||||
{
|
||||
var card = await db.Cards.FirstOrDefaultAsync(c => c.Id == cardId);
|
||||
if (card is null)
|
||||
{
|
||||
// Foil twins follow the universal +1 convention (card_id ends in 1). Marking
|
||||
// IsFoil here keeps test setup tidy so foil-resolution tests don't have to
|
||||
// hand-patch the card row.
|
||||
var isFoil = cardId % 10 == 1;
|
||||
card = new ShadowverseCardEntry { Id = cardId, Name = $"SeededCard{cardId}", Rarity = Database.Enums.Rarity.Bronze, IsFoil = isFoil };
|
||||
db.Cards.Add(card);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
private static ICardAcquisitionService GetService(SVSimTestFactory factory)
|
||||
{
|
||||
var scope = factory.Services.CreateScope();
|
||||
return scope.ServiceProvider.GetRequiredService<ICardAcquisitionService>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GrantAsync_NewBronzeCard_GrantsCardOnly()
|
||||
{
|
||||
// 101111010 is a synthetic test card (inserted ad-hoc via grantableCardIds) with no
|
||||
// CardCosmeticReward associations. Expectation: grant returns only the type=5 entry.
|
||||
using var factory = new SVSimTestFactory();
|
||||
var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 101111010L });
|
||||
|
||||
var service = GetService(factory);
|
||||
var result = await service.GrantAsync(viewerId, new[] { 101111010L });
|
||||
|
||||
Assert.That(result.RewardList, Has.Count.EqualTo(1));
|
||||
Assert.That(result.RewardList[0].RewardType, Is.EqualTo(5)); // Card
|
||||
Assert.That(result.RewardList[0].RewardId, Is.EqualTo(101111010L));
|
||||
Assert.That(result.RewardList[0].RewardNum, Is.EqualTo(1)); // post-state count
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GrantAsync_LeaderCard_GrantsCardAndSkin()
|
||||
{
|
||||
// Card 704741010 (Aria leader-card variant) has 3 cosmetic associations in the seed:
|
||||
// skin 407, sleeve 704741010, emblem 704741010.
|
||||
using var factory = new SVSimTestFactory();
|
||||
var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741010L });
|
||||
|
||||
// Since SqliteFriendlyModelCustomizer strips CardCosmeticReward seed in tests, insert
|
||||
// the specific mappings we need for this test.
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
db.CardCosmeticRewards.AddRange(
|
||||
new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 },
|
||||
new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Sleeve, CosmeticId = 704741010L, Quantity = 1 },
|
||||
new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Emblem, CosmeticId = 704741010L, Quantity = 1 }
|
||||
);
|
||||
// Ensure master rows exist for the cosmetics we'll grant
|
||||
if (await db.LeaderSkins.FindAsync(407) is null)
|
||||
db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" });
|
||||
if (await db.Sleeves.FindAsync(704741010) is null)
|
||||
db.Sleeves.Add(new SleeveEntry { Id = 704741010 });
|
||||
if (await db.Emblems.FindAsync(704741010) is null)
|
||||
db.Emblems.Add(new EmblemEntry { Id = 704741010 });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var service = GetService(factory);
|
||||
var result = await service.GrantAsync(viewerId, new[] { 704741010L });
|
||||
|
||||
var skinEntry = result.RewardList.SingleOrDefault(r => r.RewardType == 10);
|
||||
Assert.That(skinEntry, Is.Not.Null, "expected a Skin reward entry");
|
||||
Assert.That(skinEntry!.RewardId, Is.EqualTo(407L));
|
||||
|
||||
// Verify viewer ownership was actually written to DB
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var viewer = await db.Viewers
|
||||
.Include(v => v.LeaderSkins)
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
Assert.That(viewer.LeaderSkins.Any(s => s.Id == 407), Is.True);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GrantAsync_AlreadyOwnedSkin_OmitsFromRewardList()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741010L });
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
// Pre-grant the skin to this viewer
|
||||
var viewer = await db.Viewers.Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId);
|
||||
var skin = await db.LeaderSkins.FindAsync(407) ?? db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" }).Entity;
|
||||
if (!viewer.LeaderSkins.Any(s => s.Id == 407))
|
||||
viewer.LeaderSkins.Add(skin);
|
||||
// Seed the card→skin mapping
|
||||
db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var service = GetService(factory);
|
||||
var result = await service.GrantAsync(viewerId, new[] { 704741010L });
|
||||
|
||||
Assert.That(result.RewardList.Any(r => r.RewardType == 10), Is.False,
|
||||
"skin entry should be omitted since viewer already owns it");
|
||||
Assert.That(result.RewardList.Any(r => r.RewardType == 5 && r.RewardId == 704741010L), Is.True,
|
||||
"card grant entry should still be emitted");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GrantAsync_FoilLeaderCard_ResolvesToNonFoilCosmetics()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741011L });
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
// CardCosmeticReward.CardId has a FK→Cards.Id; ensure the non-foil row exists
|
||||
// even though we never grant it directly (the foil twin is the granted card).
|
||||
await EnsureCardAsync(db, 704741010L);
|
||||
// Map cosmetics to the NON-FOIL card_id (704741010), as the seed convention requires
|
||||
db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 });
|
||||
if (await db.LeaderSkins.FindAsync(407) is null)
|
||||
db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var service = GetService(factory);
|
||||
var result = await service.GrantAsync(viewerId, new[] { 704741011L });
|
||||
|
||||
var skinEntry = result.RewardList.SingleOrDefault(r => r.RewardType == 10);
|
||||
Assert.That(skinEntry, Is.Not.Null, "expected skin entry via foil resolution");
|
||||
Assert.That(skinEntry!.RewardId, Is.EqualTo(407L));
|
||||
|
||||
using var scope2 = factory.Services.CreateScope();
|
||||
var db2 = scope2.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var viewer = await db2.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId);
|
||||
Assert.That(viewer.Cards.Any(c => c.Card.Id == 704741011L), Is.True, "card is the foil");
|
||||
Assert.That(viewer.LeaderSkins.Any(s => s.Id == 407), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GrantAsync_MultipleCopiesOfSameLeader_GrantsCosmeticOnce()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741010L });
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 });
|
||||
if (await db.LeaderSkins.FindAsync(407) is null)
|
||||
db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var service = GetService(factory);
|
||||
var result = await service.GrantAsync(viewerId, new[] { 704741010L, 704741010L, 704741010L });
|
||||
|
||||
Assert.That(result.RewardList.Count(r => r.RewardType == 10), Is.EqualTo(1),
|
||||
"skin should appear exactly once in reward_list");
|
||||
var cardEntry = result.RewardList.Single(r => r.RewardType == 5 && r.RewardId == 704741010L);
|
||||
Assert.That(cardEntry.RewardNum, Is.EqualTo(3), "card count should reflect all 3 copies");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GrantAsync_RecentLeaderCard_GrantsAllFiveCosmeticTypes()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 721141010L });
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
// All 5 cosmetic types for this card. Exact ids: from data_dumps captures.
|
||||
db.CardCosmeticRewards.AddRange(
|
||||
new CardCosmeticReward { CardId = 721141010L, Type = CosmeticType.Sleeve, CosmeticId = 721141010L, Quantity = 1 },
|
||||
new CardCosmeticReward { CardId = 721141010L, Type = CosmeticType.Emblem, CosmeticId = 721141010L, Quantity = 1 },
|
||||
new CardCosmeticReward { CardId = 721141010L, Type = CosmeticType.Degree, CosmeticId = 120021L, Quantity = 1 },
|
||||
new CardCosmeticReward { CardId = 721141010L, Type = CosmeticType.Skin, CosmeticId = 4601L, Quantity = 1 },
|
||||
new CardCosmeticReward { CardId = 721141010L, Type = CosmeticType.MyPageBG, CosmeticId = 721141010L, Quantity = 1 }
|
||||
);
|
||||
// Ensure master rows
|
||||
if (await db.LeaderSkins.FindAsync(4601) is null)
|
||||
db.LeaderSkins.Add(new LeaderSkinEntry { Id = 4601, Name = "TestSkin4601" });
|
||||
if (await db.Sleeves.FindAsync(721141010) is null)
|
||||
db.Sleeves.Add(new SleeveEntry { Id = 721141010 });
|
||||
if (await db.Emblems.FindAsync(721141010) is null)
|
||||
db.Emblems.Add(new EmblemEntry { Id = 721141010 });
|
||||
if (await db.Degrees.FindAsync(120021) is null)
|
||||
db.Degrees.Add(new DegreeEntry { Id = 120021 });
|
||||
if (await db.MyPageBackgrounds.FindAsync(721141010) is null)
|
||||
db.MyPageBackgrounds.Add(new MyPageBackgroundEntry { Id = 721141010 });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var service = GetService(factory);
|
||||
var result = await service.GrantAsync(viewerId, new[] { 721141010L });
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result.RewardList.Any(r => r.RewardType == 6), Is.True, "Sleeve");
|
||||
Assert.That(result.RewardList.Any(r => r.RewardType == 7), Is.True, "Emblem");
|
||||
Assert.That(result.RewardList.Any(r => r.RewardType == 8), Is.True, "Degree");
|
||||
Assert.That(result.RewardList.Any(r => r.RewardType == 10), Is.True, "Skin");
|
||||
Assert.That(result.RewardList.Any(r => r.RewardType == 15), Is.True, "MyPageBG");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GrantAsync_BackfillMode_DoesNotIncrementCardCount()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
// Pre-seed viewer with card 704741010 count=5, no skin
|
||||
var viewerId = await SeedViewerWithCards(factory, new() { [704741010L] = 5 });
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 });
|
||||
if (await db.LeaderSkins.FindAsync(407) is null)
|
||||
db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var service = GetService(factory);
|
||||
var result = await service.GrantAsync(viewerId, newCardIds: null);
|
||||
|
||||
using var scope2 = factory.Services.CreateScope();
|
||||
var db2 = scope2.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var viewer = await db2.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId);
|
||||
var owned = viewer.Cards.Single(c => c.Card.Id == 704741010L);
|
||||
|
||||
Assert.That(owned.Count, Is.EqualTo(5), "card count should be unchanged in backfill mode");
|
||||
Assert.That(viewer.LeaderSkins.Any(s => s.Id == 407), Is.True, "skin should be backfilled");
|
||||
Assert.That(result.RewardList.Any(r => r.RewardType == 10 && r.RewardId == 407L), Is.True,
|
||||
"skin entry returned even in backfill mode");
|
||||
Assert.That(result.RewardList.Any(r => r.RewardType == 5), Is.False,
|
||||
"no type=5 card entries in backfill mode");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GrantAsync_BackfillCalledTwice_SecondCallIsNoOp()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
var viewerId = await SeedViewerWithCards(factory, new() { [704741010L] = 1 });
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 });
|
||||
if (await db.LeaderSkins.FindAsync(407) is null)
|
||||
db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var service = GetService(factory);
|
||||
var first = await service.GrantAsync(viewerId, newCardIds: null);
|
||||
var second = await service.GrantAsync(viewerId, newCardIds: null);
|
||||
|
||||
Assert.That(first.RewardList, Is.Not.Empty, "first call should grant cosmetics");
|
||||
Assert.That(second.RewardList, Is.Empty, "second call should be a no-op");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GrantAsync_LeaderCardWithMissingMapping_GrantsCardSilently()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 701141010L });
|
||||
|
||||
// NO CardCosmeticReward rows inserted for this card — simulates the 83 missing-mapping cases.
|
||||
|
||||
var service = GetService(factory);
|
||||
var result = await service.GrantAsync(viewerId, new[] { 701141010L });
|
||||
|
||||
Assert.That(result.RewardList.Any(r => r.RewardType == 5 && r.RewardId == 701141010L), Is.True);
|
||||
Assert.That(result.RewardList.Any(r => r.RewardType == 10), Is.False);
|
||||
// No exception means it handled the missing mapping gracefully.
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GrantAsync_OrphanCosmeticReward_LogsWarningAndSkips()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741010L });
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
// Real skin association
|
||||
db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 });
|
||||
if (await db.LeaderSkins.FindAsync(407) is null)
|
||||
db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" });
|
||||
|
||||
// ORPHAN: points to non-existent skin_id
|
||||
db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 9999999L, Quantity = 1 });
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var service = GetService(factory);
|
||||
var result = await service.GrantAsync(viewerId, new[] { 704741010L });
|
||||
|
||||
Assert.That(result.RewardList.Any(r => r.RewardType == 5 && r.RewardId == 704741010L), Is.True);
|
||||
Assert.That(result.RewardList.Any(r => r.RewardType == 10 && r.RewardId == 407L), Is.True,
|
||||
"real skin should still be granted");
|
||||
Assert.That(result.RewardList.Any(r => r.RewardType == 10 && r.RewardId == 9999999L), Is.False,
|
||||
"orphan cosmetic should not appear in reward_list");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user