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");
+ }
+ }
}