Phase 3 shipped the AI rank battle bot pool as a hardcoded 8-entry list inlined in SVSim.EmulatedEntrypoint/Matching/BotRoster.cs — editing meant recompiling. Per PLAN.md 2026-06-02 item (d), move it to a Bootstrap importer so the roster lives in seeds/bot-roster.json and the DB. Shape mirrors PracticeOpponent end-to-end: - BotRosterEntry (SVSim.Database/Models) — PK = AiId via the Id passthrough pattern. DbSet<BotRosterEntry> BotRoster on SVSimDbContext. - AddBotRoster migration (DDL only, per migrations-are-DDL-only rule). - seeds/bot-roster.json — 8 rows preserving the current prod-verified cosmetic ids (sleeve 704141010 / emblem 400001100 / degree 120027 / field 5) and series-1 ai_ids from rm_ai_setting.csv (1111..1181). - BotRosterSeed POCO + BotRosterImporter (idempotent upsert keyed by AiId, leaves seed-missing rows intact). Wired into SVSim.Bootstrap/Program.cs next to PracticeOpponentImporter. - IGlobalsRepository.GetBotRoster() + impl. IBotRoster.Pick → PickAsync because BotRoster now depends on the transient IGlobalsRepository. RankBattleController awaits the new signature. The deterministic hash-on-ctx invariant (same ctx → same bot, so /ai_<fmt>/start retries pick the same opponent) is preserved. DI: AddSingleton<IBotRoster> → AddTransient (matches IGlobalsRepository's lifetime). Test fixture's SeedGlobalsAsync also runs the importer so RankBattleControllerTests + the rewritten BotRosterTests both see seeded rows. Tests: 931 → 936 passing. Existing 3 BotRosterTests reshaped for the DB backing + 1 new "throws on empty roster" guard; 4 new BotRosterImporterTests mirror PracticeOpponentImporterTests (round-trip / idempotent / seed-missing-row-intact / ai_id=0 skip). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
584 lines
27 KiB
C#
584 lines
27 KiB
C#
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
using Microsoft.AspNetCore.TestHost;
|
|
using Microsoft.Data.Sqlite;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
using SVSim.Bootstrap.Importers;
|
|
using SVSim.Database;
|
|
using SVSim.Database.Enums;
|
|
using SVSim.Database.Models;
|
|
using SVSim.Database.Repositories.BattlePass;
|
|
using SVSim.Database.Repositories.Deck;
|
|
using SVSim.Database.Repositories.Viewer;
|
|
using SVSim.EmulatedEntrypoint;
|
|
using SVSim.EmulatedEntrypoint.Security.SteamSessionAuthentication;
|
|
|
|
namespace SVSim.UnitTests.Infrastructure;
|
|
|
|
/// <summary>
|
|
/// Test host for the EmulatedEntrypoint app. Each instance opens a private SQLite in-memory
|
|
/// database, swaps the production DbContext + Steam auth handler for SQLite-friendly +
|
|
/// header-driven test versions, and exposes a <see cref="SeedViewerAsync"/> helper for tests
|
|
/// to create realistic viewer rows.
|
|
/// </summary>
|
|
internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
|
|
{
|
|
private readonly SqliteConnection _connection;
|
|
private long _nextSeededShortUdid = 400_000_001;
|
|
private readonly bool _freeplayEnabled;
|
|
|
|
public SVSimTestFactory(bool freeplayEnabled = false)
|
|
{
|
|
_freeplayEnabled = freeplayEnabled;
|
|
// SQLite :memory: lives only as long as a connection is open — keep ours open for the
|
|
// factory's lifetime so the DbContext can reattach to the same DB across scopes.
|
|
_connection = new SqliteConnection("DataSource=:memory:");
|
|
_connection.Open();
|
|
}
|
|
|
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
|
{
|
|
// Tell Program.cs we're in tests so it skips UpdateDatabase() — the Postgres-targeted
|
|
// migrations would fail against SQLite. We call EnsureCreated below instead.
|
|
builder.UseEnvironment("Testing");
|
|
|
|
builder.ConfigureTestServices(services =>
|
|
{
|
|
ReplaceDbContext(services);
|
|
ReplaceAuthHandler(services);
|
|
});
|
|
}
|
|
|
|
protected override IHost CreateHost(IHostBuilder builder)
|
|
{
|
|
var host = base.CreateHost(builder);
|
|
|
|
using var scope = host.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
db.Database.EnsureCreated();
|
|
db.EnsureSeedDataAsync().GetAwaiter().GetResult();
|
|
|
|
if (_freeplayEnabled)
|
|
{
|
|
using var seedScope = host.Services.CreateScope();
|
|
var seedDb = seedScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
const string freeplayJson = "{\"Enabled\":true,\"CurrencyAmount\":99999,\"CardCopies\":3}";
|
|
var existing = seedDb.GameConfigs.FirstOrDefault(s => s.SectionName == "Freeplay");
|
|
if (existing is null)
|
|
seedDb.GameConfigs.Add(new SVSim.Database.Models.GameConfigSection { SectionName = "Freeplay", ValueJson = freeplayJson });
|
|
else
|
|
existing.ValueJson = freeplayJson;
|
|
seedDb.SaveChanges();
|
|
}
|
|
|
|
// Reference data is no longer HasData-seeded; load the CSVs via the same importer
|
|
// production uses so tests exercise the same code path. CardCosmeticRewards skipped —
|
|
// FK to Cards would reject every row against the minimal 3-card test seed below.
|
|
var dataDir = Path.Combine(AppContext.BaseDirectory, "Data");
|
|
new ReferenceDataImporter(TextWriter.Null, TextWriter.Null)
|
|
.ImportAllAsync(db, dataDir).GetAwaiter().GetResult();
|
|
|
|
// Seed a minimal card set so card-pool tests can resolve a non-empty pool without
|
|
// requiring the full CardImporter tool or a cards.json file. The set is marked
|
|
// IsInRotation so both standard-pack (by setId) and special-pack (rotation scan)
|
|
// tests see real data.
|
|
SeedMinimalCardSet(db);
|
|
SeedMinimalPackDrawTable(db);
|
|
|
|
return host;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Seeds a minimal PackDrawConfig + slot rates + card weights for the test card-set's
|
|
/// cards (10001001/10001002/10001003) under pack id 10001. Lets PackController.Open
|
|
/// resolve a draw table without requiring tests to run the full PackDrawTableImporter.
|
|
/// </summary>
|
|
private static void SeedMinimalPackDrawTable(SVSimDbContext db)
|
|
{
|
|
if (db.PackDrawConfigs.Any())
|
|
return;
|
|
|
|
const int packId = 10001;
|
|
db.PackDrawConfigs.Add(new PackDrawConfigEntry { Id = packId, AnimationRatePct = 0 });
|
|
// Slot rates: uniform single-tier so any rng lands somewhere valid.
|
|
db.PackDrawSlotRates.Add(new PackDrawSlotRateEntry { PackId = packId, Slot = DrawSlot.General, Tier = DrawTier.Bronze, RatePct = 100 });
|
|
db.PackDrawSlotRates.Add(new PackDrawSlotRateEntry { PackId = packId, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, RatePct = 100 });
|
|
// Card weights for both slots.
|
|
db.PackDrawCardWeights.Add(new PackDrawCardWeightEntry { PackId = packId, Slot = DrawSlot.General, Tier = DrawTier.Bronze, CardId = 10001001, RatePct = 100 });
|
|
db.PackDrawCardWeights.Add(new PackDrawCardWeightEntry { PackId = packId, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, CardId = 10001001, RatePct = 100 });
|
|
db.SaveChanges();
|
|
}
|
|
|
|
private static void SeedMinimalCardSet(SVSimDbContext db)
|
|
{
|
|
if (db.CardSets.Any())
|
|
return; // Already seeded (e.g. if CreateHost is called more than once)
|
|
|
|
var set = new ShadowverseCardSetEntry
|
|
{
|
|
Id = 10001,
|
|
Name = "TestSet",
|
|
IsInRotation = true,
|
|
IsBasic = false,
|
|
Cards =
|
|
[
|
|
new ShadowverseCardEntry { Id = 10001001L, Name = "TestCard1", Rarity = Rarity.Bronze },
|
|
new ShadowverseCardEntry { Id = 10001002L, Name = "TestCard2", Rarity = Rarity.Gold },
|
|
new ShadowverseCardEntry { Id = 10001003L, Name = "TestCard3", Rarity = Rarity.Legendary },
|
|
]
|
|
};
|
|
db.CardSets.Add(set);
|
|
db.SaveChanges();
|
|
}
|
|
|
|
private void ReplaceDbContext(IServiceCollection services)
|
|
{
|
|
// Production registered DbContextOptions<SVSimDbContext> with the Npgsql provider; tear
|
|
// out every related descriptor so AddDbContext below installs a clean SQLite-backed one.
|
|
foreach (var descriptor in services
|
|
.Where(d => d.ServiceType == typeof(DbContextOptions<SVSimDbContext>)
|
|
|| d.ServiceType == typeof(DbContextOptions)
|
|
|| d.ServiceType == typeof(SVSimDbContext))
|
|
.ToList())
|
|
{
|
|
services.Remove(descriptor);
|
|
}
|
|
|
|
services.AddDbContext<SVSimDbContext>(opt =>
|
|
{
|
|
opt.UseSqlite(_connection);
|
|
opt.ReplaceService<Microsoft.EntityFrameworkCore.Infrastructure.IModelCustomizer, SqliteFriendlyModelCustomizer>();
|
|
});
|
|
}
|
|
|
|
private static void ReplaceAuthHandler(IServiceCollection services)
|
|
{
|
|
// Production Program.cs registered SteamSessionAuthenticationHandler under the
|
|
// "SteamAuthentication" scheme. Drop that scheme from BOTH the SchemeMap and the
|
|
// parallel Schemes list (AddScheme writes to both — and the provider iterates the
|
|
// list, not the map, so leaving the old builder behind throws "Scheme already exists"
|
|
// when it re-adds during provider construction).
|
|
services.AddTransient<TestAuthHandler>();
|
|
services.PostConfigure<AuthenticationOptions>(opt =>
|
|
{
|
|
opt.SchemeMap.Remove(SteamAuthenticationConstants.SchemeName, out _);
|
|
var schemesList = (IList<AuthenticationSchemeBuilder>)opt.Schemes;
|
|
foreach (var stale in schemesList
|
|
.Where(s => s.Name == SteamAuthenticationConstants.SchemeName)
|
|
.ToList())
|
|
{
|
|
schemesList.Remove(stale);
|
|
}
|
|
|
|
opt.AddScheme(SteamAuthenticationConstants.SchemeName, b =>
|
|
{
|
|
b.HandlerType = typeof(TestAuthHandler);
|
|
});
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a fully-formed viewer via the real <see cref="IViewerRepository.RegisterViewer"/>
|
|
/// path (so the test exercises the same nav-graph wiring real users hit). The viewer's
|
|
/// <c>ShortUdid</c> is overwritten to a unique non-zero value because the Postgres sequence
|
|
/// is disabled on SQLite — without this every test viewer collides on 0.
|
|
/// </summary>
|
|
public async Task<long> SeedViewerAsync(
|
|
ulong steamId = 76_561_198_000_000_001UL,
|
|
string displayName = "Test Viewer",
|
|
int tutorialState = 100)
|
|
{
|
|
long viewerId;
|
|
long shortUdid;
|
|
|
|
using (var scope = Services.CreateScope())
|
|
{
|
|
var repo = scope.ServiceProvider.GetRequiredService<IViewerRepository>();
|
|
var v = await repo.RegisterViewer(displayName, SocialAccountType.Steam, steamId);
|
|
viewerId = v.Id;
|
|
shortUdid = Interlocked.Increment(ref _nextSeededShortUdid);
|
|
}
|
|
|
|
// Second scope: assign a real ShortUdid so claim-based lookups in tests have something
|
|
// to find (and so per-viewer ShortUdids don't collide across SeedViewerAsync calls).
|
|
using (var scope = Services.CreateScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
|
|
v.ShortUdid = shortUdid;
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
// Third scope: write the requested TutorialState. The parameter defaults to 100 —
|
|
// the post-tutorial baseline that ~30 existing tests rely on — so callers that don't
|
|
// care about the tutorial step keep working unchanged. Pass tutorialState: 1 to seed
|
|
// a fresh-signup viewer, or any other value to land mid-tutorial. RegisterViewer's
|
|
// own default (set in BuildDefaultViewer) is irrelevant here because this override
|
|
// always runs.
|
|
using (var scope = Services.CreateScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
var viewer = await db.Viewers.Include(v => v.MissionData).FirstAsync(v => v.Id == viewerId);
|
|
viewer.MissionData.TutorialState = tutorialState;
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
return viewerId;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Runs the per-domain seed importers against the test SQLite DB using the seed JSON
|
|
/// copied into the test output dir (see SVSim.UnitTests.csproj Content Includes for
|
|
/// Data/seeds and Data/test-fixtures/seeds). Idempotent — safe to call multiple times.
|
|
/// Tests that depend on prod-shaped global content (spot_cards, avatar abilities, etc.)
|
|
/// call this once during setup; the rest of the test runs against whatever the importers
|
|
/// populated. Mirrors the wiring in <see cref="SVSim.Bootstrap.Program"/>.
|
|
/// </summary>
|
|
public async Task SeedGlobalsAsync()
|
|
{
|
|
string seedDir = Path.Combine(AppContext.BaseDirectory, "Data", "seeds");
|
|
using var scope = Services.CreateScope();
|
|
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
|
|
// RotationConfigImporter must precede RotationFlagUpdater; CardListsImporter is
|
|
// ordered after the GameConfig importers for tidiness (no FK dependency).
|
|
await new RotationConfigImporter().ImportAsync(ctx, seedDir);
|
|
await new MyRotationImporter().ImportAsync(ctx, seedDir);
|
|
await new AvatarAbilityImporter().ImportAsync(ctx, seedDir);
|
|
await new ArenaSeasonImporter().ImportAsync(ctx, seedDir);
|
|
await new BattlePassImporter().ImportAsync(ctx, seedDir);
|
|
await new BattlePassSeasonImporter().ImportAsync(ctx, seedDir);
|
|
await new BattlePassRewardImporter().ImportAsync(ctx, seedDir);
|
|
await new DailyLoginBonusImporter().ImportAsync(ctx, seedDir);
|
|
await new PreReleaseInfoImporter().ImportAsync(ctx, seedDir);
|
|
await new CardListsImporter().ImportAsync(ctx, seedDir);
|
|
await new RotationFlagUpdater().UpdateAsync(ctx);
|
|
|
|
await new PracticeOpponentImporter().ImportAsync(ctx, seedDir);
|
|
await new BotRosterImporter().ImportAsync(ctx, seedDir);
|
|
await new PaymentItemImporter().ImportAsync(ctx, seedDir);
|
|
await new ItemImporter().ImportAsync(ctx, seedDir);
|
|
await new SleeveShopImporter().ImportAsync(ctx, seedDir);
|
|
await new ItemPurchaseImporter().ImportAsync(ctx, seedDir);
|
|
await new LeaderSkinShopImporter().ImportAsync(ctx, seedDir);
|
|
await new SpotCardExchangeImporter().ImportAsync(ctx, seedDir);
|
|
var puzzleImporter = new PuzzleImporter();
|
|
await puzzleImporter.ImportGroupsAsync(ctx, seedDir);
|
|
await puzzleImporter.ImportPuzzlesAsync(ctx, seedDir);
|
|
await puzzleImporter.ImportMissionsAsync(ctx, seedDir);
|
|
|
|
var mypage = new MyPageGlobalsImporter();
|
|
await mypage.ImportBannersAsync(ctx, seedDir);
|
|
await mypage.ImportColosseumAsync(ctx, seedDir);
|
|
await mypage.ImportSealedAsync(ctx, seedDir);
|
|
await mypage.ImportMasterPointRankingPeriodAsync(ctx, seedDir);
|
|
await mypage.ImportSpecialDeckFormatsAsync(ctx, seedDir);
|
|
|
|
await new DefaultDeckImporter().ImportAsync(ctx, seedDir);
|
|
await new PackImporter().ImportAsync(ctx, seedDir);
|
|
// PackDrawTableImporter is NOT called here — production draw tables reference real
|
|
// Cygames card_ids not present in the test's minimal card master. Tests that
|
|
// exercise /pack/open use SeedPackDrawTableAsync to install a stub draw table
|
|
// pointing to their seeded test cards.
|
|
}
|
|
|
|
/// <summary>
|
|
/// Installs a minimal PackDrawConfig + slot rates + per-card weights for <paramref name="packId"/>,
|
|
/// pointing the per-card weights at <paramref name="cardIds"/>. All cards land in the Bronze tier
|
|
/// at 100% rate; slot 1-7 and slot 8 both draw from the same pool. Use for tests that need
|
|
/// /pack/open to succeed against a custom seeded card pool.
|
|
/// </summary>
|
|
public Task SeedPackDrawTableAsync(int packId, params long[] cardIds)
|
|
=> SeedPackDrawTableAsync(packId, DrawTier.Bronze, cardIds);
|
|
|
|
/// <summary>
|
|
/// Convenience for gacha-point tests: picks Legendary cards from <paramref name="cardSetId"/>
|
|
/// (skipping foils) and seeds them as the draw table's Legendary tier for <paramref name="packId"/>.
|
|
/// </summary>
|
|
public async Task SeedPackDrawTableFromSetAsync(int packId, int cardSetId)
|
|
{
|
|
using var scope = Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
|
|
var legendaryIds = await db.CardSets
|
|
.Where(s => s.Id == cardSetId)
|
|
.SelectMany(s => s.Cards)
|
|
.Where(c => c.Rarity == SVSim.Database.Enums.Rarity.Legendary && !c.IsFoil)
|
|
.Select(c => c.Id)
|
|
.ToListAsync();
|
|
|
|
if (legendaryIds.Count > 0)
|
|
{
|
|
await SeedPackDrawTableAsync(packId, DrawTier.Legendary, legendaryIds.ToArray());
|
|
}
|
|
}
|
|
|
|
public async Task SeedPackDrawTableAsync(int packId, DrawTier tier, params long[] cardIds)
|
|
{
|
|
using var scope = Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
|
|
if (await db.PackDrawConfigs.AnyAsync(c => c.Id == packId)) return;
|
|
|
|
db.PackDrawConfigs.Add(new PackDrawConfigEntry { Id = packId, AnimationRatePct = 0 });
|
|
db.PackDrawSlotRates.Add(new PackDrawSlotRateEntry { PackId = packId, Slot = DrawSlot.General, Tier = tier, RatePct = 100 });
|
|
db.PackDrawSlotRates.Add(new PackDrawSlotRateEntry { PackId = packId, Slot = DrawSlot.Eighth, Tier = tier, RatePct = 100 });
|
|
foreach (var cid in cardIds)
|
|
{
|
|
db.PackDrawCardWeights.Add(new PackDrawCardWeightEntry { PackId = packId, Slot = DrawSlot.General, Tier = tier, CardId = cid, RatePct = 100.0 / cardIds.Length });
|
|
db.PackDrawCardWeights.Add(new PackDrawCardWeightEntry { PackId = packId, Slot = DrawSlot.Eighth, Tier = tier, CardId = cid, RatePct = 100.0 / cardIds.Length });
|
|
}
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enables Freeplay mode by writing the GameConfigs DB row (tier-1 of GameConfigService).
|
|
/// Call before issuing the request under test. Idempotent.
|
|
/// </summary>
|
|
public async Task EnableFreeplayAsync(ulong currencyAmount = 99999, int cardCopies = 3)
|
|
{
|
|
using var scope = Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
var json = System.Text.Json.JsonSerializer.Serialize(new
|
|
{
|
|
Enabled = true,
|
|
CurrencyAmount = currencyAmount,
|
|
CardCopies = cardCopies,
|
|
});
|
|
var existing = await db.GameConfigs.FirstOrDefaultAsync(s => s.SectionName == "Freeplay");
|
|
if (existing is null)
|
|
db.GameConfigs.Add(new GameConfigSection { SectionName = "Freeplay", ValueJson = json });
|
|
else
|
|
existing.ValueJson = json;
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
/// <summary>Convenience: bake the X-Test-Viewer-Id header into a fresh client.</summary>
|
|
public HttpClient CreateAuthenticatedClient(long viewerId)
|
|
{
|
|
var client = CreateClient();
|
|
client.DefaultRequestHeaders.Add(TestAuthHandler.ViewerIdHeader, viewerId.ToString());
|
|
return client;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Inserts a deck for the viewer via the real <see cref="IDeckRepository.UpsertDeck"/>
|
|
/// path. Picks the first seeded class/sleeve/leader-skin from the master tables; tests
|
|
/// that need specific ids should hit the DB directly.
|
|
/// </summary>
|
|
public async Task SeedDeckAsync(long viewerId, Format format, int number, string name = "Test Deck")
|
|
{
|
|
using var scope = Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
var repo = scope.ServiceProvider.GetRequiredService<IDeckRepository>();
|
|
|
|
var cls = await db.Classes.FirstAsync();
|
|
var sleeve = await db.Sleeves.FirstAsync();
|
|
var skin = await db.LeaderSkins.FirstAsync();
|
|
|
|
await repo.UpsertDeck(viewerId, format, number, d =>
|
|
{
|
|
d.Name = name;
|
|
d.Class = cls;
|
|
d.Sleeve = sleeve;
|
|
d.LeaderSkin = skin;
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Seeds an OwnedCardEntry for the viewer. Uses an existing card from the minimal test set
|
|
/// when <paramref name="cardId"/> matches one (10001001/10001002/10001003); otherwise the
|
|
/// caller must have inserted the card row themselves. <paramref name="dustReward"/> is written
|
|
/// onto the card's CollectionInfo so destruct tests can compute expected vials.
|
|
///
|
|
/// NOTE: This helper ALWAYS resets the viewer's RedEther to 0 (so destruct tests can assert
|
|
/// literal post-state totals). Callers that need a non-zero balance should re-assign after seeding.
|
|
/// </summary>
|
|
public async Task SeedOwnedCardAsync(
|
|
long viewerId,
|
|
long cardId,
|
|
int count,
|
|
int dustReward = 50,
|
|
int craftCost = 200,
|
|
bool isProtected = false)
|
|
{
|
|
using var scope = Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
|
|
var card = await db.Cards.FirstOrDefaultAsync(c => c.Id == cardId);
|
|
if (card is null)
|
|
{
|
|
card = new ShadowverseCardEntry
|
|
{
|
|
Id = cardId,
|
|
Name = $"SeededCard{cardId}",
|
|
Rarity = Rarity.Bronze,
|
|
CollectionInfo = new CardCollectionInfo { CraftCost = craftCost, DustReward = dustReward },
|
|
};
|
|
db.Cards.Add(card);
|
|
await db.SaveChangesAsync();
|
|
}
|
|
else if (card.CollectionInfo is null || card.CollectionInfo.DustReward != dustReward || card.CollectionInfo.CraftCost != craftCost)
|
|
{
|
|
card.CollectionInfo = new CardCollectionInfo { CraftCost = craftCost, DustReward = dustReward };
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId);
|
|
var owned = viewer.Cards.FirstOrDefault(c => c.Card.Id == cardId);
|
|
if (owned is null)
|
|
{
|
|
viewer.Cards.Add(new OwnedCardEntry { Card = card, Count = count, IsProtected = isProtected });
|
|
}
|
|
else
|
|
{
|
|
owned.Count = count;
|
|
owned.IsProtected = isProtected;
|
|
}
|
|
viewer.Currency.RedEther = 0; // Reset RedEther so destruct tests can assert literal post-state totals
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Seeds a bare <see cref="ShadowverseCardEntry"/> (no viewer ownership) and returns its id.
|
|
/// Used by InventoryGrantCardTests to get a valid card id without also seeding owned state.
|
|
/// Ids start at 800_000_000 (non-foil) or 800_000_001 (foil) and increment by 2 per call to
|
|
/// keep foil twins aligned.
|
|
/// </summary>
|
|
public async Task<long> SeedCardAsync(bool isFoil = false)
|
|
{
|
|
using var scope = Services.CreateScope();
|
|
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
long id = isFoil ? 800_000_001L : 800_000_000L;
|
|
while (await ctx.Cards.AnyAsync(c => c.Id == id)) id += 2;
|
|
ctx.Cards.Add(new ShadowverseCardEntry { Id = id, IsFoil = isFoil, Name = $"SeedCard{id}" });
|
|
await ctx.SaveChangesAsync();
|
|
return id;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets the viewer's RedEther balance to <paramref name="amount"/>. Call this AFTER
|
|
/// <see cref="SeedOwnedCardAsync"/>, which resets RedEther to 0. Create tests use this
|
|
/// to give the viewer enough vials to craft.
|
|
/// </summary>
|
|
public async Task SetRedEtherAsync(long viewerId, ulong amount)
|
|
{
|
|
using var scope = Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
var viewer = await db.Viewers.FirstAsync(v => v.Id == viewerId);
|
|
viewer.Currency.RedEther = amount;
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Puts <paramref name="count"/> copies of <paramref name="cardId"/> into the viewer's deck
|
|
/// in the given format + slot. Tests use this to set up deck-strip scenarios for /card/destruct.
|
|
/// The card must already exist (typically via SeedOwnedCardAsync, which inserts the card row).
|
|
/// </summary>
|
|
public async Task AddCardToDeckAsync(long viewerId, Format format, int deckNumber, long cardId, int count)
|
|
{
|
|
using var scope = Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
|
|
var viewer = await db.Viewers
|
|
.Include(v => v.Decks).ThenInclude(d => d.Cards).ThenInclude(c => c.Card)
|
|
.FirstAsync(v => v.Id == viewerId);
|
|
|
|
var deck = viewer.Decks.First(d => d.Format == format && d.Number == deckNumber);
|
|
var card = await db.Cards.FirstAsync(c => c.Id == cardId);
|
|
|
|
var existing = deck.Cards.FirstOrDefault(c => c.Card.Id == cardId);
|
|
if (existing is null)
|
|
{
|
|
deck.Cards.Add(new DeckCard { Card = card, Count = count });
|
|
}
|
|
else
|
|
{
|
|
existing.Count = count;
|
|
}
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the viewer's current <c>TutorialState</c> from the DB.
|
|
/// Tests use this to verify that <c>/tutorial/update</c> persisted the step.
|
|
/// </summary>
|
|
public async Task<int> GetViewerTutorialStateAsync(long viewerId)
|
|
{
|
|
using var scope = Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
var viewer = await db.Viewers.Include(v => v.MissionData).FirstAsync(v => v.Id == viewerId);
|
|
return viewer.MissionData.TutorialState;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the viewer's current currency balances from the DB. Used by gift_receive tests
|
|
/// to assert delta grants after claiming tutorial presents.
|
|
/// </summary>
|
|
public async Task<(ulong Crystals, ulong Rupees, ulong RedEther)> GetViewerCurrencyAsync(long viewerId)
|
|
{
|
|
using var scope = Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
var viewer = await db.Viewers.FirstAsync(v => v.Id == viewerId);
|
|
return (viewer.Currency.Crystals, viewer.Currency.Rupees, viewer.Currency.RedEther);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Seeds an OwnedItemEntry for the viewer. Inserts the ItemEntry master row if missing
|
|
/// (Type defaults to 2 = card-pack ticket since both tutorial gift items 80001 and 90001
|
|
/// are tickets). Tests use this to set up the ticket inventory that /tutorial/pack_open
|
|
/// is supposed to consume.
|
|
/// </summary>
|
|
public async Task SeedOwnedItemAsync(long viewerId, int itemId, int count, string itemName = "TestItem", int itemType = 2)
|
|
{
|
|
using var scope = Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
var item = await db.Items.FindAsync(itemId);
|
|
if (item is null)
|
|
{
|
|
item = new ItemEntry { Id = itemId, Name = itemName, Type = itemType };
|
|
db.Items.Add(item);
|
|
await db.SaveChangesAsync();
|
|
}
|
|
var viewer = await db.Viewers
|
|
.Include(v => v.Items).ThenInclude(i => i.Item)
|
|
.FirstAsync(v => v.Id == viewerId);
|
|
var existing = viewer.Items.FirstOrDefault(i => i.Item.Id == itemId);
|
|
if (existing is null)
|
|
{
|
|
viewer.Items.Add(new OwnedItemEntry { Item = item, Count = count, Viewer = viewer });
|
|
}
|
|
else
|
|
{
|
|
existing.Count = count;
|
|
}
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the viewer's current owned count for <paramref name="itemId"/>. Returns 0 if no
|
|
/// row exists. Tests use this to assert ticket consumption after /tutorial/pack_open.
|
|
/// </summary>
|
|
public async Task<int> GetOwnedItemCountAsync(long viewerId, int itemId)
|
|
{
|
|
using var scope = Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
var viewer = await db.Viewers
|
|
.Include(v => v.Items).ThenInclude(i => i.Item)
|
|
.FirstAsync(v => v.Id == viewerId);
|
|
return viewer.Items.FirstOrDefault(i => i.Item.Id == itemId)?.Count ?? 0;
|
|
}
|
|
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
base.Dispose(disposing);
|
|
if (disposing)
|
|
{
|
|
_connection.Dispose();
|
|
}
|
|
}
|
|
}
|