From 72c8fe627ba6071ae5701c583ec2001d174be2b2 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 30 May 2026 21:45:06 -0400 Subject: [PATCH] feat(packs): PackDrawTableImporter with fixture tests Idempotent upsert keyed on pack_id; slot rates and card weights are wiped per pack and reinserted. String slot/tier in the seed translate to enum at import time. Tests: - Imports_config_slot_rates_and_card_weights - Is_idempotent_on_rerun Fixtures live under SVSim.Bootstrap/Data/test-fixtures/seeds/ and overlay the production seeds via the existing csproj rule (test-fixture file beats production file at same Link path). Co-Authored-By: Claude Opus 4.7 --- .../seeds/pack-draw-card-weights.json | 7 ++ .../test-fixtures/seeds/pack-draw-config.json | 4 + .../seeds/pack-draw-slot-rates.json | 10 ++ .../Importers/PackDrawTableImporter.cs | 102 ++++++++++++++++++ .../Importers/PackDrawTableImporterTests.cs | 48 +++++++++ 5 files changed, 171 insertions(+) create mode 100644 SVSim.Bootstrap/Data/test-fixtures/seeds/pack-draw-card-weights.json create mode 100644 SVSim.Bootstrap/Data/test-fixtures/seeds/pack-draw-config.json create mode 100644 SVSim.Bootstrap/Data/test-fixtures/seeds/pack-draw-slot-rates.json create mode 100644 SVSim.Bootstrap/Importers/PackDrawTableImporter.cs create mode 100644 SVSim.UnitTests/Importers/PackDrawTableImporterTests.cs diff --git a/SVSim.Bootstrap/Data/test-fixtures/seeds/pack-draw-card-weights.json b/SVSim.Bootstrap/Data/test-fixtures/seeds/pack-draw-card-weights.json new file mode 100644 index 0000000..5ce386a --- /dev/null +++ b/SVSim.Bootstrap/Data/test-fixtures/seeds/pack-draw-card-weights.json @@ -0,0 +1,7 @@ +[ + { "pack_id": 10000, "slot": "general", "tier": "bronze", "card_id": 100, "rate_pct": 50.0, "is_leader": false, "is_alt_art": false }, + { "pack_id": 10000, "slot": "general", "tier": "bronze", "card_id": 101, "rate_pct": 26.5, "is_leader": false, "is_alt_art": false }, + { "pack_id": 10000, "slot": "general", "tier": "legendary", "card_id": 200, "rate_pct": 1.5, "is_leader": false, "is_alt_art": false }, + { "pack_id": 98001, "slot": "bonus", "tier": "special", "card_id": 300, "rate_pct": null, "is_leader": true, "is_alt_art": false }, + { "pack_id": 98001, "slot": "bonus", "tier": "special", "card_id": 301, "rate_pct": null, "is_leader": true, "is_alt_art": false } +] diff --git a/SVSim.Bootstrap/Data/test-fixtures/seeds/pack-draw-config.json b/SVSim.Bootstrap/Data/test-fixtures/seeds/pack-draw-config.json new file mode 100644 index 0000000..a256566 --- /dev/null +++ b/SVSim.Bootstrap/Data/test-fixtures/seeds/pack-draw-config.json @@ -0,0 +1,4 @@ +[ + { "pack_id": 10000, "short_code": "Basic", "animation_rate_pct": 8.0, "has_bonus_slot": false, "special_kind": null }, + { "pack_id": 98001, "short_code": "98ANV", "animation_rate_pct": 8.0, "has_bonus_slot": true, "special_kind": "leader_card" } +] diff --git a/SVSim.Bootstrap/Data/test-fixtures/seeds/pack-draw-slot-rates.json b/SVSim.Bootstrap/Data/test-fixtures/seeds/pack-draw-slot-rates.json new file mode 100644 index 0000000..42b2a4a --- /dev/null +++ b/SVSim.Bootstrap/Data/test-fixtures/seeds/pack-draw-slot-rates.json @@ -0,0 +1,10 @@ +[ + { "pack_id": 10000, "slot": "general", "tier": "bronze", "rate_pct": 76.5 }, + { "pack_id": 10000, "slot": "general", "tier": "silver", "rate_pct": 16.0 }, + { "pack_id": 10000, "slot": "general", "tier": "gold", "rate_pct": 6.0 }, + { "pack_id": 10000, "slot": "general", "tier": "legendary", "rate_pct": 1.5 }, + { "pack_id": 10000, "slot": "eighth", "tier": "silver", "rate_pct": 92.5 }, + { "pack_id": 10000, "slot": "eighth", "tier": "gold", "rate_pct": 6.0 }, + { "pack_id": 10000, "slot": "eighth", "tier": "legendary", "rate_pct": 1.5 }, + { "pack_id": 98001, "slot": "bonus", "tier": "special", "rate_pct": 100.0 } +] diff --git a/SVSim.Bootstrap/Importers/PackDrawTableImporter.cs b/SVSim.Bootstrap/Importers/PackDrawTableImporter.cs new file mode 100644 index 0000000..7ff2a21 --- /dev/null +++ b/SVSim.Bootstrap/Importers/PackDrawTableImporter.cs @@ -0,0 +1,102 @@ +using Microsoft.EntityFrameworkCore; +using SVSim.Bootstrap.Models.Seed; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.Database.Models; + +namespace SVSim.Bootstrap.Importers; + +/// +/// Idempotent upsert of the per-pack draw table from +/// seeds/pack-draw-config.json, pack-draw-slot-rates.json, and +/// pack-draw-card-weights.json. Replaces wholesale per pack (config keyed on +/// pack_id; slot rates / card weights wiped and reinserted) — the upstream data is +/// post-shutdown closed, so we do not preserve hand-edits on these tables. +/// +public class PackDrawTableImporter +{ + public async Task ImportAsync(SVSimDbContext context, string seedDir) + { + var configs = SeedLoader.LoadList(Path.Combine(seedDir, "pack-draw-config.json")); + var slotRates = SeedLoader.LoadList(Path.Combine(seedDir, "pack-draw-slot-rates.json")); + var cardWeights = SeedLoader.LoadList(Path.Combine(seedDir, "pack-draw-card-weights.json")); + + if (configs.Count == 0) + { + Console.WriteLine("[PackDrawTableImporter] No seed rows; skipping."); + return 0; + } + + var seedPackIds = configs.Select(c => c.PackId).ToHashSet(); + + // Full-replace strategy: wipe rows for any pack in the seed, then reinsert. + await context.PackDrawCardWeights + .Where(w => seedPackIds.Contains(w.PackId)) + .ExecuteDeleteAsync(); + await context.PackDrawSlotRates + .Where(s => seedPackIds.Contains(s.PackId)) + .ExecuteDeleteAsync(); + + var existingConfigs = await context.PackDrawConfigs + .Where(c => seedPackIds.Contains(c.Id)) + .ToDictionaryAsync(c => c.Id); + + foreach (var s in configs) + { + var row = existingConfigs.TryGetValue(s.PackId, out var ex) + ? ex : new PackDrawConfigEntry { Id = s.PackId }; + row.AnimationRatePct = s.AnimationRatePct; + row.HasBonusSlot = s.HasBonusSlot; + row.SpecialKind = s.SpecialKind; + row.ShortCode = s.ShortCode; + if (ex is null) context.PackDrawConfigs.Add(row); + } + + foreach (var s in slotRates) + { + context.PackDrawSlotRates.Add(new PackDrawSlotRateEntry + { + PackId = s.PackId, + Slot = ParseSlot(s.Slot), + Tier = ParseTier(s.Tier), + RatePct = s.RatePct, + }); + } + + foreach (var s in cardWeights) + { + context.PackDrawCardWeights.Add(new PackDrawCardWeightEntry + { + PackId = s.PackId, + Slot = ParseSlot(s.Slot), + Tier = ParseTier(s.Tier), + CardId = s.CardId, + RatePct = s.RatePct, + IsLeader = s.IsLeader, + IsAltArt = s.IsAltArt, + }); + } + + await context.SaveChangesAsync(); + Console.WriteLine($"[PackDrawTableImporter] {configs.Count} configs / {slotRates.Count} slot rates / {cardWeights.Count} card weights"); + return configs.Count; + } + + private static DrawSlot ParseSlot(string s) => s switch + { + "general" => DrawSlot.General, + "eighth" => DrawSlot.Eighth, + "bonus" => DrawSlot.Bonus, + _ => throw new InvalidDataException($"PackDrawTableImporter: unknown slot \"{s}\""), + }; + + private static DrawTier ParseTier(string s) => s switch + { + "bronze" => DrawTier.Bronze, + "silver" => DrawTier.Silver, + "gold" => DrawTier.Gold, + "legendary" => DrawTier.Legendary, + "special" => DrawTier.Special, + _ => throw new InvalidDataException($"PackDrawTableImporter: unknown tier \"{s}\""), + }; +} diff --git a/SVSim.UnitTests/Importers/PackDrawTableImporterTests.cs b/SVSim.UnitTests/Importers/PackDrawTableImporterTests.cs new file mode 100644 index 0000000..01709e6 --- /dev/null +++ b/SVSim.UnitTests/Importers/PackDrawTableImporterTests.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Bootstrap.Importers; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Importers; + +public class PackDrawTableImporterTests +{ + private static string SeedDir => Path.Combine(AppContext.BaseDirectory, "Data", "seeds"); + + [Test] + public async Task Imports_config_slot_rates_and_card_weights() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + await new PackDrawTableImporter().ImportAsync(db, SeedDir); + + Assert.That(await db.PackDrawConfigs.CountAsync(), Is.EqualTo(2)); + Assert.That(await db.PackDrawSlotRates.CountAsync(), Is.EqualTo(8)); + Assert.That(await db.PackDrawCardWeights.CountAsync(), Is.EqualTo(5)); + + var bonus = await db.PackDrawCardWeights + .Where(w => w.PackId == 98001 && w.Slot == DrawSlot.Bonus) + .ToListAsync(); + Assert.That(bonus.Count, Is.EqualTo(2)); + Assert.That(bonus.All(w => w.RatePct == null && w.IsLeader && w.Tier == DrawTier.Special), Is.True); + } + + [Test] + public async Task Is_idempotent_on_rerun() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + await new PackDrawTableImporter().ImportAsync(db, SeedDir); + int before = await db.PackDrawCardWeights.CountAsync(); + await new PackDrawTableImporter().ImportAsync(db, SeedDir); + int after = await db.PackDrawCardWeights.CountAsync(); + + Assert.That(after, Is.EqualTo(before)); + } +}