diff --git a/SVSim.Database/Services/IViewerEntitlements.cs b/SVSim.Database/Services/IViewerEntitlements.cs index 1a3ba83..fd5d23d 100644 --- a/SVSim.Database/Services/IViewerEntitlements.cs +++ b/SVSim.Database/Services/IViewerEntitlements.cs @@ -8,6 +8,14 @@ namespace SVSim.Database.Services; /// Freeplay flag; all freeplay read-side behavior lives here. See /// docs/superpowers/specs/2026-05-29-freeplay-mode-design.md. /// +/// +/// Include precondition: methods that inspect the viewer's collections require the +/// viewer to have been loaded with .Include(v => v.Cards).ThenInclude(c => c.Card) +/// and the cosmetic collections +/// (Sleeves, Emblems, Degrees, LeaderSkins, MyPageBackgrounds) +/// included. Without those includes the EF owned-collection nav refs are null or zero-filled +/// (see the EF owned-collection nav-include pitfall in MEMORY.md). +/// public interface IViewerEntitlements { /// True when the global Freeplay config section is enabled. diff --git a/SVSim.UnitTests/Services/ViewerEntitlementsTests.cs b/SVSim.UnitTests/Services/ViewerEntitlementsTests.cs index 7fde5fd..91665a0 100644 --- a/SVSim.UnitTests/Services/ViewerEntitlementsTests.cs +++ b/SVSim.UnitTests/Services/ViewerEntitlementsTests.cs @@ -96,4 +96,173 @@ public class ViewerEntitlementsTests Assert.That(ent.OwnsCard(viewer, 99_999_999L), Is.True); Assert.That(ent.OwnsCosmetic(viewer, CosmeticType.Skin, 99_999), Is.True); } + + // ------------------------------------------------------------------------- + // EffectiveOwnedCardsAsync + // ------------------------------------------------------------------------- + + [Test] + public async Task EffectiveOwnedCards_freeplay_on_returns_all_collectible_cards_at_card_copies() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + // Seed one collectible card owned by this viewer (gives it a CollectionInfo). + await factory.SeedOwnedCardAsync(viewerId, 50001001L, count: 1); + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + SetFreeplayEnabled(db, enabled: true, cardCopies: 3); + + var viewer = await db.Viewers + .Include(v => v.Cards).ThenInclude(c => c.Card) + .FirstAsync(v => v.Id == viewerId); + + var ent = Build(scope); + var result = await ent.EffectiveOwnedCardsAsync(viewer); + + // Freeplay returns the whole collectible catalog — card 50001001 must be present. + Assert.That(result.Any(e => e.Card.Id == 50001001L), Is.True, + "seeded collectible card must appear in freeplay result"); + + // Every returned entry must have Count == CardCopies (3). + Assert.That(result.All(e => e.Count == 3), Is.True, + "every freeplay entry should have Count == CardCopies (3)"); + + // The full set == every collectible card in the DB. + int collectibleCount = db.Cards.Count(c => c.CollectionInfo != null); + Assert.That(result.Count, Is.EqualTo(collectibleCount), + "freeplay result should contain exactly all collectible cards"); + } + + [Test] + public async Task EffectiveOwnedCards_freeplay_off_returns_only_owned() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + // Seed card 50001002 owned at count 2. + await factory.SeedOwnedCardAsync(viewerId, 50001002L, count: 2); + + // Seed a second collectible card (50001003) NOT owned by the viewer — insert card row + // only (with CollectionInfo so it's collectible) but do not link it to the viewer. + using (var setupScope = factory.Services.CreateScope()) + { + var setupDb = setupScope.ServiceProvider.GetRequiredService(); + if (!setupDb.Cards.Any(c => c.Id == 50001003L)) + { + setupDb.Cards.Add(new ShadowverseCardEntry + { + Id = 50001003L, + Name = "UnownedCollectible", + Rarity = SVSim.Database.Enums.Rarity.Bronze, + CollectionInfo = new SVSim.Database.Models.CardCollectionInfo { CraftCost = 200, DustReward = 50 }, + }); + await setupDb.SaveChangesAsync(); + } + } + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + // Freeplay is off by default — no mutation needed. + + var viewer = await db.Viewers + .Include(v => v.Cards).ThenInclude(c => c.Card) + .FirstAsync(v => v.Id == viewerId); + + var ent = Build(scope); + var result = await ent.EffectiveOwnedCardsAsync(viewer); + + // The owned card must be present at the right count. + var owned = result.FirstOrDefault(e => e.Card.Id == 50001002L); + Assert.That(owned, Is.Not.Null, "owned card should appear in result"); + Assert.That(owned!.Count, Is.EqualTo(2)); + + // The unowned collectible card must NOT appear. + Assert.That(result.Any(e => e.Card.Id == 50001003L), Is.False, + "card not owned by viewer must not appear when freeplay is off"); + } + + // ------------------------------------------------------------------------- + // EffectiveCosmeticsAsync + // ------------------------------------------------------------------------- + + [Test] + public async Task EffectiveCosmetics_leader_skins_always_full_catalog_owned_set_differs() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + + // --- freeplay OFF: fresh viewer owns no cosmetics --- + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + // Freeplay off by default. + + var viewer = await db.Viewers + .Include(v => v.LeaderSkins) + .Include(v => v.Sleeves) + .Include(v => v.Emblems) + .Include(v => v.Degrees) + .Include(v => v.MyPageBackgrounds) + .FirstAsync(v => v.Id == viewerId); + + var ent = Build(scope); + var cosmetics = await ent.EffectiveCosmeticsAsync(viewer); + + int masterSkinCount = db.LeaderSkins.Count(); + Assert.That(masterSkinCount, Is.GreaterThan(0), + "leaderskins.csv must have been imported — master table must be non-empty"); + Assert.That(cosmetics.AllLeaderSkins.Count, Is.EqualTo(masterSkinCount), + "AllLeaderSkins should always be the full catalog regardless of freeplay"); + + // A fresh viewer owns one default skin per class (granted at registration). + // Assert the owned set matches what the viewer actually has — don't assume empty. + var expectedOwnedSkinIds = viewer.LeaderSkins.Select(s => s.Id).ToHashSet(); + Assert.That(cosmetics.OwnedLeaderSkinIds, Is.EquivalentTo(expectedOwnedSkinIds), + "OwnedLeaderSkinIds should match the viewer's actual owned skins when freeplay is off"); + + // OwnedLeaderSkinIds must be a strict subset of AllLeaderSkins (not all of them). + Assert.That(cosmetics.OwnedLeaderSkinIds.Count, Is.LessThan(masterSkinCount), + "fresh viewer should own fewer skins than the full catalog"); + + // The four id-lists reflect what the viewer actually owns. + Assert.That(cosmetics.SleeveIds.Count, Is.EqualTo(viewer.Sleeves.Count)); + Assert.That(cosmetics.EmblemIds.Count, Is.EqualTo(viewer.Emblems.Count)); + Assert.That(cosmetics.DegreeIds.Count, Is.EqualTo(viewer.Degrees.Count)); + Assert.That(cosmetics.MyPageBackgroundIds.Count, Is.EqualTo(viewer.MyPageBackgrounds.Count)); + } + + // --- freeplay ON: all catalogs become owned --- + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + SetFreeplayEnabled(db, enabled: true); + + var viewer = await db.Viewers + .Include(v => v.LeaderSkins) + .Include(v => v.Sleeves) + .Include(v => v.Emblems) + .Include(v => v.Degrees) + .Include(v => v.MyPageBackgrounds) + .FirstAsync(v => v.Id == viewerId); + + var ent = Build(scope); + var cosmetics = await ent.EffectiveCosmeticsAsync(viewer); + + int masterSkinCount = db.LeaderSkins.Count(); + Assert.That(cosmetics.AllLeaderSkins.Count, Is.EqualTo(masterSkinCount), + "AllLeaderSkins count unchanged when freeplay is on"); + Assert.That(cosmetics.OwnedLeaderSkinIds.Count, Is.EqualTo(masterSkinCount), + "freeplay: every skin id must be in OwnedLeaderSkinIds"); + + // All four id-lists should equal the full catalog counts. + Assert.That(cosmetics.SleeveIds.Count, Is.EqualTo(db.Sleeves.Count()), + "freeplay: SleeveIds should equal full sleeve catalog"); + Assert.That(cosmetics.EmblemIds.Count, Is.EqualTo(db.Emblems.Count()), + "freeplay: EmblemIds should equal full emblem catalog"); + Assert.That(cosmetics.DegreeIds.Count, Is.EqualTo(db.Degrees.Count()), + "freeplay: DegreeIds should equal full degree catalog"); + Assert.That(cosmetics.MyPageBackgroundIds.Count, Is.EqualTo(db.MyPageBackgrounds.Count()), + "freeplay: MyPageBackgroundIds should equal full my-page-background catalog"); + } + } }