Pack logic cleanup

This commit is contained in:
gamer147
2026-05-24 09:27:10 -04:00
parent 79209bd70b
commit d9ef9fe1fc
33 changed files with 71175 additions and 245 deletions

View File

@@ -56,6 +56,7 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
using var scope = host.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.Database.EnsureCreated();
db.EnsureSeedDataAsync().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

View File

@@ -0,0 +1,75 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.Database.Models;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Models;
public class GameConfigurationJsonbTests
{
[Test]
public async Task DefaultSeed_populates_canonical_GameConfigRoot_defaults()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var cfg = await db.GameConfigurations.FirstOrDefaultAsync(c => c.Id == "default");
Assert.That(cfg, Is.Not.Null, "default GameConfiguration row must exist (seeded via EnsureSeedDataAsync)");
Assert.That(cfg!.Config, Is.Not.Null, "Config must round-trip to non-null GameConfigRoot");
Assert.That(cfg.Config.DefaultGrants.Crystals, Is.EqualTo(50000UL), "pre-refactor default");
Assert.That(cfg.Config.DefaultGrants.Rupees, Is.EqualTo(50000UL), "pre-refactor default");
Assert.That(cfg.Config.DefaultGrants.Ether, Is.EqualTo(50000UL), "pre-refactor default");
Assert.That(cfg.Config.Player.MaxFriends, Is.EqualTo(20), "pre-refactor default");
Assert.That(cfg.Config.DefaultLoadout.SleeveId, Is.EqualTo(3000011), "pre-refactor default sleeve");
Assert.That(cfg.Config.PackRates.AnimatedRate, Is.EqualTo(0.08).Within(1e-9), "SV Classic default");
Assert.That(cfg.Config.PackRates.Default.Bronze, Is.EqualTo(0.6744).Within(1e-9));
// PerSlot is now a List<SlotRarityWeights> keyed by Slot string (see Task 5 deviation note).
var slot8 = cfg.Config.PackRates.PerSlot.FirstOrDefault(s => s.Slot == "8");
Assert.That(slot8, Is.Not.Null, "slot-8 default entry must be present");
Assert.That(slot8!.Silver, Is.EqualTo(0.7692).Within(1e-9));
}
[Test]
public async Task Mutation_then_save_then_reload_round_trips_through_jsonb()
{
using var factory = new SVSimTestFactory();
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var cfg = await db.GameConfigurations.FirstAsync(c => c.Id == "default");
cfg.Config.Rotation.TsRotationId = "99999";
cfg.Config.PackRates.AnimatedRate = 0.42;
db.Entry(cfg).Property(c => c.Config).IsModified = true;
await db.SaveChangesAsync();
}
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var cfg = await db.GameConfigurations.FirstAsync(c => c.Id == "default");
Assert.That(cfg.Config.Rotation.TsRotationId, Is.EqualTo("99999"));
Assert.That(cfg.Config.PackRates.AnimatedRate, Is.EqualTo(0.42).Within(1e-9));
}
}
[Test]
public async Task GlobalsImporter_updates_Rotation_without_clobbering_other_subconfigs()
{
using var factory = new SVSimTestFactory();
await factory.SeedGlobalsAsync(); // imports load-index which has ts_rotation_id="10015"
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var cfg = await db.GameConfigurations.FirstAsync(c => c.Id == "default");
Assert.That(cfg.Config.Rotation.TsRotationId, Is.EqualTo("10015"),
"GlobalsImporter should set Rotation.TsRotationId from the prod capture.");
// PackRates is NOT in the load-index capture; must keep the seeded default unchanged.
Assert.That(cfg.Config.PackRates.AnimatedRate, Is.EqualTo(0.08).Within(1e-9),
"GlobalsImporter must not clobber PackRates while updating Rotation.");
}
}

View File

@@ -213,9 +213,9 @@ public class GlobalsRepositoryTests
using var _ = factory;
var cfg = await repo.GetGameConfiguration("default");
Assert.That(cfg, Is.Not.Null);
Assert.That(cfg.TsRotationId, Is.EqualTo("10015"),
Assert.That(cfg.Config.Rotation.TsRotationId, Is.EqualTo("10015"),
"GlobalsImporter should overwrite the migration's empty-string default with the capture value.");
Assert.That(cfg.IsBattlePassPeriod, Is.True,
Assert.That(cfg.Config.Rotation.IsBattlePassPeriod, Is.True,
"Prod sends bool true for is_battle_pass_period; capture should overwrite the migration default of false.");
}

View File

@@ -64,4 +64,85 @@ public class DbCardPoolProviderTests
Assert.That(pool, Is.Empty);
}
[Test]
public async Task GetPool_excludes_foil_cards()
{
using var factory = new SVSimTestFactory();
long nonFoilId, foilId;
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
// Pick the highest-Id card so that id+1 is guaranteed unoccupied.
nonFoilId = await db.Cards.OrderByDescending(c => c.Id).Select(c => c.Id).FirstAsync();
foilId = nonFoilId + 1;
var foilCard = new ShadowverseCardEntry
{
Id = foilId, Name = $"Card {foilId}", Rarity = Rarity.Bronze, IsFoil = true,
};
// Add directly to the Cards DbSet and set the FK via shadow property,
// avoiding nav-collection tracker conflicts.
db.Cards.Add(foilCard);
db.Entry(foilCard).Property("ShadowverseCardSetEntryId").CurrentValue = 10001;
await db.SaveChangesAsync();
}
using var scope2 = factory.Services.CreateScope();
var provider = scope2.ServiceProvider.GetRequiredService<ICardPoolProvider>();
var pool = provider.GetPool(new PackConfigEntry
{
Id = 10001, BasePackId = 10001,
PackCategory = PackCategory.None,
});
Assert.That(pool.Any(c => c.Id == nonFoilId), Is.True, "non-foil must be in the pool");
Assert.That(pool.Any(c => c.Id == foilId), Is.False, "foil must be excluded from the pool");
}
[Test]
public async Task TryGetFoilTwin_returns_the_id_plus_one_foil_when_present()
{
using var factory = new SVSimTestFactory();
long nonFoilId, foilId;
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
// Pick the highest-Id card so that id+1 is guaranteed unoccupied.
nonFoilId = await db.Cards.OrderByDescending(c => c.Id).Select(c => c.Id).FirstAsync();
foilId = nonFoilId + 1;
var foilCard = new ShadowverseCardEntry
{
Id = foilId, Name = $"Card {foilId}", Rarity = Rarity.Bronze, IsFoil = true,
};
db.Cards.Add(foilCard);
db.Entry(foilCard).Property("ShadowverseCardSetEntryId").CurrentValue = 10001;
await db.SaveChangesAsync();
}
using var scope2 = factory.Services.CreateScope();
var provider = scope2.ServiceProvider.GetRequiredService<ICardPoolProvider>();
var twin = provider.TryGetFoilTwin(nonFoilId);
Assert.That(twin, Is.Not.Null);
Assert.That(twin!.Id, Is.EqualTo(foilId));
Assert.That(twin.IsFoil, Is.True);
}
[Test]
public async Task TryGetFoilTwin_returns_null_when_no_foil_at_id_plus_one()
{
using var factory = new SVSimTestFactory();
long anyCardId;
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
anyCardId = await db.Cards.OrderBy(c => c.Id).Select(c => c.Id).FirstAsync();
}
using var scope2 = factory.Services.CreateScope();
var provider = scope2.ServiceProvider.GetRequiredService<ICardPoolProvider>();
Assert.That(provider.TryGetFoilTwin(anyCardId), Is.Null,
"no foil seeded at anyCardId+1, so TryGetFoilTwin must return null");
}
}

View File

@@ -1,5 +1,6 @@
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Models.Config;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.UnitTests.Services;
@@ -21,6 +22,7 @@ public class PackOpenServiceTests
private readonly IReadOnlyList<ShadowverseCardEntry> _cards;
public StubPool(IReadOnlyList<ShadowverseCardEntry> cards) { _cards = cards; }
public IReadOnlyList<ShadowverseCardEntry> GetPool(PackConfigEntry _) => _cards;
public ShadowverseCardEntry? TryGetFoilTwin(long baseCardId) => null;
}
private static List<ShadowverseCardEntry> MakeFourCards() => new()
@@ -39,7 +41,8 @@ public class PackOpenServiceTests
[Test]
public void Draw_returns_eight_cards_for_one_pack()
{
var svc = new PackOpenService();
var rootDefault = new GameConfigRoot(); // canonical SV Classic defaults
var svc = new PackOpenService(rootDefault);
var pool = new StubPool(MakeFourCards());
var rng = new ScriptedRandom(0.5); // anything in Bronze/Silver range for non-slot-8
@@ -51,7 +54,8 @@ public class PackOpenServiceTests
[Test]
public void Draw_slot_8_never_returns_bronze_for_standard_pack()
{
var svc = new PackOpenService();
var rootDefault = new GameConfigRoot(); // canonical SV Classic defaults
var svc = new PackOpenService(rootDefault);
var pool = new StubPool(MakeFourCards());
for (int trial = 0; trial < 1000; trial++)
@@ -65,7 +69,8 @@ public class PackOpenServiceTests
[Test]
public void Draw_legendary_special_forces_slot_8_to_legendary()
{
var svc = new PackOpenService();
var rootDefault = new GameConfigRoot(); // canonical SV Classic defaults
var svc = new PackOpenService(rootDefault);
var pool = new StubPool(MakeFourCards());
var pack = new PackConfigEntry { Id = 92001, BasePackId = 90001, PackCategory = PackCategory.SpecialCardPack };
@@ -80,7 +85,8 @@ public class PackOpenServiceTests
[Test]
public void Draw_distribution_over_10k_slots_1_to_7_matches_declared_rates_within_2_percent()
{
var svc = new PackOpenService();
var rootDefault = new GameConfigRoot(); // canonical SV Classic defaults
var svc = new PackOpenService(rootDefault);
var pool = new StubPool(MakeFourCards());
var counts = new Dictionary<Rarity, int>
{
@@ -111,7 +117,8 @@ public class PackOpenServiceTests
[Test]
public void Draw_excludes_listed_card_ids()
{
var svc = new PackOpenService();
var rootDefault = new GameConfigRoot(); // canonical SV Classic defaults
var svc = new PackOpenService(rootDefault);
// Pool with two bronze cards; exclude one — every Bronze slot must pick the other.
var pool = new StubPool(new List<ShadowverseCardEntry>
{
@@ -128,4 +135,112 @@ public class PackOpenServiceTests
Assert.That(c.CardId, Is.EqualTo(99), "excluded card 1 must never appear in Bronze slot");
}
}
[Test]
public void Draw_per_slot_override_is_applied_for_that_slot_and_default_for_others()
{
// Config: slot 3 is forced to Legendary; everything else uses Default.
// PerSlot is a List<SlotRarityWeights> with a Slot string key (EF Core 8 deviation from
// the Dictionary<int, T> in the spec — see Task 5 notes).
var root = new GameConfigRoot();
root.PackRates.PerSlot.Add(new SlotRarityWeights
{
Slot = "3",
Bronze = 0, Silver = 0, Gold = 0, Legendary = 1.0,
});
var svc = new PackOpenService(root);
var pool = new StubPool(MakeFourCards());
for (int trial = 0; trial < 50; trial++)
{
var result = svc.Draw(StandardPack(), pool, 1, Array.Empty<long>(), new SystemRandom(trial));
Assert.That(result.Cards[2].Rarity, Is.EqualTo(Rarity.Legendary),
$"slot 3 must be Legendary under PerSlot[3] override (trial {trial})");
}
}
/// <summary>StubPool variant that also implements TryGetFoilTwin via the SAME id+1 convention
/// as the DB-backed provider, but keyed off an injected dictionary so tests stay hermetic.</summary>
private sealed class StubPoolWithFoils : ICardPoolProvider
{
private readonly IReadOnlyList<ShadowverseCardEntry> _pool;
private readonly Dictionary<long, ShadowverseCardEntry> _foilsByBaseId;
public StubPoolWithFoils(IReadOnlyList<ShadowverseCardEntry> pool, Dictionary<long, ShadowverseCardEntry> foilsByBaseId)
{
_pool = pool;
_foilsByBaseId = foilsByBaseId;
}
public IReadOnlyList<ShadowverseCardEntry> GetPool(PackConfigEntry _) => _pool;
public ShadowverseCardEntry? TryGetFoilTwin(long baseCardId) =>
_foilsByBaseId.TryGetValue(baseCardId, out var f) ? f : null;
}
[Test]
public void Draw_animated_rate_upgrades_about_8_percent_of_slots_within_tolerance()
{
// One bronze card with a foil twin; rate = 0.08; ~8% of 8000 slots should be foil.
var bronze = new ShadowverseCardEntry { Id = 1, Rarity = Rarity.Bronze, IsFoil = false };
var bronzeFoil = new ShadowverseCardEntry { Id = 2, Rarity = Rarity.Bronze, IsFoil = true };
var pools = new StubPoolWithFoils(
new List<ShadowverseCardEntry> { bronze },
new Dictionary<long, ShadowverseCardEntry> { [bronze.Id] = bronzeFoil });
var root = new GameConfigRoot(); // default AnimatedRate = 0.08
var svc = new PackOpenService(root);
const int packs = 1_000; // 8000 slots
int foilCount = 0;
var rng = new SystemRandom(seed: 7);
for (int i = 0; i < packs; i++)
{
var r = svc.Draw(StandardPack(), pools, 1, Array.Empty<long>(), rng);
foilCount += r.Cards.Count(c => c.CardId == bronzeFoil.Id);
}
double rate = foilCount / (double)(packs * 8);
Assert.That(rate, Is.EqualTo(0.08).Within(0.015),
$"observed animated rate {rate:P} outside the ±1.5% tolerance of 8%");
}
[Test]
public void Draw_animated_upgrade_silently_keeps_base_when_no_foil_twin_exists()
{
var bronze = new ShadowverseCardEntry { Id = 1, Rarity = Rarity.Bronze, IsFoil = false };
var pools = new StubPoolWithFoils(
new List<ShadowverseCardEntry> { bronze },
new Dictionary<long, ShadowverseCardEntry>()); // no foils
// Force the animated roll to always hit by setting AnimatedRate = 1.0.
var root = new GameConfigRoot();
root.PackRates.AnimatedRate = 1.0;
var svc = new PackOpenService(root);
var r = svc.Draw(StandardPack(), pools, 1, Array.Empty<long>(), new SystemRandom(seed: 1));
foreach (var c in r.Cards)
{
Assert.That(c.CardId, Is.EqualTo(bronze.Id),
"no foil twin available; every slot must keep the base card despite 100% animated rate");
}
}
[Test]
public void Draw_animated_upgrade_applies_to_slot_8_including_legendary_specials()
{
var leg = new ShadowverseCardEntry { Id = 4, Rarity = Rarity.Legendary, IsFoil = false };
var legFoil = new ShadowverseCardEntry { Id = 5, Rarity = Rarity.Legendary, IsFoil = true };
var pools = new StubPoolWithFoils(
new List<ShadowverseCardEntry> { leg },
new Dictionary<long, ShadowverseCardEntry> { [leg.Id] = legFoil });
var root = new GameConfigRoot();
root.PackRates.AnimatedRate = 1.0; // every slot upgrades
var svc = new PackOpenService(root);
var specialPack = new PackConfigEntry { Id = 92001, BasePackId = 90001, PackCategory = PackCategory.SpecialCardPack };
var r = svc.Draw(specialPack, pools, 1, Array.Empty<long>(), new SystemRandom(seed: 3));
// Slot 8 is forced Legendary by the structural rule; with AnimatedRate=1.0 it must be the foil legendary.
Assert.That(r.Cards[7].CardId, Is.EqualTo(legFoil.Id),
"legendary-special slot 8 must be the foil-legendary when animated rate is forced to 1.0");
}
}