Pack logic cleanup
This commit is contained in:
@@ -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
|
||||
|
||||
75
SVSim.UnitTests/Models/GameConfigurationJsonbTests.cs
Normal file
75
SVSim.UnitTests/Models/GameConfigurationJsonbTests.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user