Additional card content

This commit is contained in:
gamer147
2026-05-24 17:07:05 -04:00
parent 12fb2f4801
commit 34bcc579a5
18 changed files with 53025 additions and 16 deletions

View File

@@ -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);
}
}

View 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,
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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!;
}

View File

@@ -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

View File

@@ -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):
//

View File

@@ -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
{

File diff suppressed because it is too large Load Diff

View File

@@ -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

View 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;
}
}
}

View 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);

View 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);
}

View File

@@ -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");
}
}
}

View File

@@ -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();
}
}

View File

@@ -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>

View 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");
}
}