diff --git a/SVSim.Database/Services/ViewerEntitlements.cs b/SVSim.Database/Services/ViewerEntitlements.cs new file mode 100644 index 0000000..4e0a137 --- /dev/null +++ b/SVSim.Database/Services/ViewerEntitlements.cs @@ -0,0 +1,107 @@ +using SVSim.Database.Enums; +using SVSim.Database.Models; +using SVSim.Database.Models.Config; +using SVSim.Database.Repositories.Card; +using SVSim.Database.Repositories.Collectibles; + +namespace SVSim.Database.Services; + +public class ViewerEntitlements : IViewerEntitlements +{ + private readonly IGameConfigService _config; + private readonly ICardRepository _cards; + private readonly ICollectionRepository _collection; + + public ViewerEntitlements(IGameConfigService config, ICardRepository cards, ICollectionRepository collection) + { + _config = config; + _cards = cards; + _collection = collection; + } + + private FreeplayConfig Cfg => _config.Get(); + + public bool IsFreeplay => Cfg.Enabled; + + public long EffectiveBalance(Viewer viewer, SpendCurrency currency) + { + var cfg = Cfg; + if (cfg.Enabled && currency != SpendCurrency.SpotPoint) + return checked((long)cfg.CurrencyAmount); + + return currency switch + { + SpendCurrency.Crystal => (long)viewer.Currency.Crystals, + SpendCurrency.Rupee => (long)viewer.Currency.Rupees, + SpendCurrency.RedEther => (long)viewer.Currency.RedEther, + SpendCurrency.SpotPoint => (long)viewer.Currency.SpotPoints, + _ => throw new ArgumentOutOfRangeException(nameof(currency)), + }; + } + + public bool OwnsCard(Viewer viewer, long cardId) + => Cfg.Enabled || viewer.Cards.Any(c => c.Card.Id == cardId && c.Count > 0); + + public bool OwnsCosmetic(Viewer viewer, CosmeticType type, int id) + { + if (Cfg.Enabled) return true; + return type switch + { + CosmeticType.Sleeve => viewer.Sleeves.Any(s => s.Id == id), + CosmeticType.Emblem => viewer.Emblems.Any(e => e.Id == id), + CosmeticType.Degree => viewer.Degrees.Any(d => d.Id == id), + CosmeticType.Skin => viewer.LeaderSkins.Any(s => s.Id == id), + CosmeticType.MyPageBG => viewer.MyPageBackgrounds.Any(m => m.Id == id), + _ => false, + }; + } + + public async Task> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default) + { + var defaults = await _cards.GetDefaultCards(); + var defaultIds = defaults.Select(c => c.Id).ToHashSet(); + var cfg = Cfg; + + if (cfg.Enabled) + { + var all = await _cards.GetAll(onlyCollectible: true); + return all + .Select(c => new OwnedCardEntry + { + Card = c, + Count = cfg.CardCopies, + IsProtected = defaultIds.Contains(c.Id), + }) + .ToList(); + } + + var owned = viewer.Cards.Where(c => c.Count > 0 && !defaultIds.Contains(c.Card.Id)); + return owned + .Concat(defaults.Select(bc => new OwnedCardEntry { Card = bc, Count = 3, IsProtected = true })) + .ToList(); + } + + public async Task EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default) + { + var allSkins = await _collection.GetLeaderSkins(); + + if (Cfg.Enabled) + { + return new EffectiveCosmetics( + await _collection.GetAllSleeveIds(), + await _collection.GetAllEmblemIds(), + await _collection.GetAllDegreeIds(), + await _collection.GetAllMyPageBackgroundIds(), + allSkins, + allSkins.Select(s => s.Id).ToHashSet()); + } + + return new EffectiveCosmetics( + viewer.Sleeves.Select(s => s.Id).ToList(), + viewer.Emblems.Select(e => e.Id).ToList(), + viewer.Degrees.Select(d => d.Id).ToList(), + viewer.MyPageBackgrounds.Select(m => m.Id).ToList(), + allSkins, + viewer.LeaderSkins.Select(s => s.Id).ToHashSet()); + } +} diff --git a/SVSim.UnitTests/Services/ViewerEntitlementsTests.cs b/SVSim.UnitTests/Services/ViewerEntitlementsTests.cs new file mode 100644 index 0000000..7fde5fd --- /dev/null +++ b/SVSim.UnitTests/Services/ViewerEntitlementsTests.cs @@ -0,0 +1,99 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.Database.Models; +using SVSim.Database.Models.Config; +using SVSim.Database.Repositories.Card; +using SVSim.Database.Repositories.Collectibles; +using SVSim.Database.Services; +using SVSim.EmulatedEntrypoint.Services; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Services; + +public class ViewerEntitlementsTests +{ + /// + /// FreeplayConfig is in SVSim.Database so EnsureSeedDataAsync seeds a DB row with + /// Enabled=false (ShippedDefaults). Since tier 1 (DB) wins, we mutate the seeded row + /// to activate freeplay rather than relying on an IConfiguration override. + /// + private static void SetFreeplayEnabled(SVSimDbContext db, bool enabled, ulong currencyAmount = 99999, int cardCopies = 3) + { + var row = db.GameConfigs.First(s => s.SectionName == "Freeplay"); + var cfg = JsonSerializer.Deserialize(row.ValueJson)!; + cfg.Enabled = enabled; + cfg.CurrencyAmount = currencyAmount; + cfg.CardCopies = cardCopies; + row.ValueJson = JsonSerializer.Serialize(cfg); + db.SaveChanges(); + } + + private static ViewerEntitlements Build(IServiceScope scope) + { + var db = scope.ServiceProvider.GetRequiredService(); + return new ViewerEntitlements( + new GameConfigService(db, new ConfigurationBuilder().Build()), + new CardRepository(db), + new CollectionRepository(db)); + } + + [Test] + public async Task Freeplay_off_reflects_real_balance_and_ownership() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + // Freeplay is seeded as Enabled=false by default — no mutation needed. + var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId); + viewer.Currency.Crystals = 7; + + var ent = Build(scope); + + Assert.That(ent.IsFreeplay, Is.False); + Assert.That(ent.EffectiveBalance(viewer, SpendCurrency.Crystal), Is.EqualTo(7)); + Assert.That(ent.OwnsCard(viewer, 99_999_999L), Is.False); + Assert.That(ent.OwnsCosmetic(viewer, CosmeticType.Skin, 99_999), Is.False); + } + + [Test] + public async Task Freeplay_on_inflates_main_currencies_but_not_spot_points() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + SetFreeplayEnabled(db, enabled: true, currencyAmount: 99999); + var viewer = await db.Viewers.FirstAsync(v => v.Id == viewerId); + viewer.Currency.SpotPoints = 5; + + var ent = Build(scope); + + Assert.That(ent.IsFreeplay, Is.True); + Assert.That(ent.EffectiveBalance(viewer, SpendCurrency.Crystal), Is.EqualTo(99999)); + Assert.That(ent.EffectiveBalance(viewer, SpendCurrency.Rupee), Is.EqualTo(99999)); + Assert.That(ent.EffectiveBalance(viewer, SpendCurrency.RedEther), Is.EqualTo(99999)); + Assert.That(ent.EffectiveBalance(viewer, SpendCurrency.SpotPoint), Is.EqualTo(5), + "spot points are not a freeplay-inflated currency"); + } + + [Test] + public async Task Freeplay_on_treats_all_cards_and_cosmetics_as_owned() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + SetFreeplayEnabled(db, enabled: true); + var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId); + + var ent = Build(scope); + + Assert.That(ent.OwnsCard(viewer, 99_999_999L), Is.True); + Assert.That(ent.OwnsCosmetic(viewer, CosmeticType.Skin, 99_999), Is.True); + } +}