From 7b5edb7c65502faeed5ad60442e22dd2ab7a3b34 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 29 May 2026 13:25:47 -0400 Subject: [PATCH 01/19] feat(config): add Freeplay config section (default off) Adds FreeplayConfig [ConfigSection("Freeplay")] with Enabled=false, CurrencyAmount=99999, CardCopies=3, and ShippedDefaults(). Covered by FreeplayConfigTests verifying the tier-chain resolves shipped defaults. Co-Authored-By: Claude Sonnet 4.6 --- .../Models/Config/FreeplayConfig.cs | 17 ++++++++++++ .../Services/FreeplayConfigTests.cs | 26 +++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 SVSim.Database/Models/Config/FreeplayConfig.cs create mode 100644 SVSim.UnitTests/Services/FreeplayConfigTests.cs diff --git a/SVSim.Database/Models/Config/FreeplayConfig.cs b/SVSim.Database/Models/Config/FreeplayConfig.cs new file mode 100644 index 0000000..9d26507 --- /dev/null +++ b/SVSim.Database/Models/Config/FreeplayConfig.cs @@ -0,0 +1,17 @@ +namespace SVSim.Database.Models.Config; + +/// +/// Global "freeplay" toggle. When , every viewer is treated (in logic, +/// never in the DB) as owning all cards ( each), all cosmetics, and +/// of Crystal/Rupee/Red-Ether. See +/// docs/superpowers/specs/2026-05-29-freeplay-mode-design.md. +/// +[ConfigSection("Freeplay")] +public class FreeplayConfig +{ + public bool Enabled { get; set; } = false; + public ulong CurrencyAmount { get; set; } = 99999; + public int CardCopies { get; set; } = 3; + + public static FreeplayConfig ShippedDefaults() => new(); +} diff --git a/SVSim.UnitTests/Services/FreeplayConfigTests.cs b/SVSim.UnitTests/Services/FreeplayConfigTests.cs new file mode 100644 index 0000000..60683a7 --- /dev/null +++ b/SVSim.UnitTests/Services/FreeplayConfigTests.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Models.Config; +using SVSim.EmulatedEntrypoint.Services; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Services; + +public class FreeplayConfigTests +{ + [Test] + public void Freeplay_defaults_to_disabled_with_canonical_amounts() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var svc = new GameConfigService(db, new ConfigurationBuilder().Build()); + + var cfg = svc.Get(); + + Assert.That(cfg.Enabled, Is.False, "freeplay must be off unless explicitly enabled"); + Assert.That(cfg.CurrencyAmount, Is.EqualTo(99999UL)); + Assert.That(cfg.CardCopies, Is.EqualTo(3)); + } +} From b4f69929183bd88d130c3a316c9b1aa5d0a49d45 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 29 May 2026 13:27:22 -0400 Subject: [PATCH 02/19] feat(services): declare entitlements + currency-spend primitives Co-Authored-By: Claude Sonnet 4.6 --- .../Services/ICurrencySpendService.cs | 14 ++++++ .../Services/IViewerEntitlements.cs | 46 +++++++++++++++++++ SVSim.Database/Services/SpendCurrency.cs | 16 +++++++ 3 files changed, 76 insertions(+) create mode 100644 SVSim.Database/Services/ICurrencySpendService.cs create mode 100644 SVSim.Database/Services/IViewerEntitlements.cs create mode 100644 SVSim.Database/Services/SpendCurrency.cs diff --git a/SVSim.Database/Services/ICurrencySpendService.cs b/SVSim.Database/Services/ICurrencySpendService.cs new file mode 100644 index 0000000..6aa27c1 --- /dev/null +++ b/SVSim.Database/Services/ICurrencySpendService.cs @@ -0,0 +1,14 @@ +using SVSim.Database.Models; + +namespace SVSim.Database.Services; + +/// +/// Centralized debit primitive — the symmetric twin of RewardGrantService.ApplyAsync. +/// Encapsulates the affordability-check + deduction + post-state-total pattern that was inlined +/// across the shop/pack controllers. Does NOT call SaveChangesAsync; the caller saves. +/// Freeplay (for Crystal/Rupee/RedEther) makes spends always succeed without deducting. +/// +public interface ICurrencySpendService +{ + Task TrySpendAsync(Viewer viewer, SpendCurrency currency, long cost, CancellationToken ct = default); +} diff --git a/SVSim.Database/Services/IViewerEntitlements.cs b/SVSim.Database/Services/IViewerEntitlements.cs new file mode 100644 index 0000000..1a3ba83 --- /dev/null +++ b/SVSim.Database/Services/IViewerEntitlements.cs @@ -0,0 +1,46 @@ +using SVSim.Database.Enums; +using SVSim.Database.Models; + +namespace SVSim.Database.Services; + +/// +/// The single read/ownership authority for what a viewer is *treated as* owning. Knows the +/// Freeplay flag; all freeplay read-side behavior lives here. See +/// docs/superpowers/specs/2026-05-29-freeplay-mode-design.md. +/// +public interface IViewerEntitlements +{ + /// True when the global Freeplay config section is enabled. + bool IsFreeplay { get; } + + /// + /// The balance the viewer is treated as having: the configured freeplay amount for + /// Crystal/Rupee/RedEther when freeplay is on, otherwise (and always for SpotPoint) the real + /// viewer.Currency field. + /// + long EffectiveBalance(Viewer viewer, SpendCurrency currency); + + bool OwnsCard(Viewer viewer, long cardId); + + /// uses (Skin == leader skin). + bool OwnsCosmetic(Viewer viewer, CosmeticType type, int id); + + /// The full owned-card projection for /load/index's user_card_list. + Task> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default); + + /// The cosmetic id-lists + leader-skin catalog/owned-set for /load/index. + Task EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default); +} + +/// +/// Cosmetic projection bundle for /load/index. The four id-lists are "what the viewer owns" +/// (all of them in freeplay). Leader skins are always the full catalog with a per-skin owned flag; +/// is every skin id in freeplay. +/// +public sealed record EffectiveCosmetics( + IReadOnlyList SleeveIds, + IReadOnlyList EmblemIds, + IReadOnlyList DegreeIds, + IReadOnlyList MyPageBackgroundIds, + IReadOnlyList AllLeaderSkins, + IReadOnlySet OwnedLeaderSkinIds); diff --git a/SVSim.Database/Services/SpendCurrency.cs b/SVSim.Database/Services/SpendCurrency.cs new file mode 100644 index 0000000..ee7f246 --- /dev/null +++ b/SVSim.Database/Services/SpendCurrency.cs @@ -0,0 +1,16 @@ +namespace SVSim.Database.Services; + +/// The scalar wallet currencies the central debit primitive understands. +public enum SpendCurrency { Crystal, Rupee, RedEther, SpotPoint } + +public enum SpendOutcome { Success, Insufficient } + +/// +/// Result of a call. +/// is the balance the client should show after the spend — the real post-deduction balance, or the +/// freeplay effective balance when the spend was a freeplay no-op. +/// +public sealed record SpendResult(SpendOutcome Outcome, long PostStateTotal) +{ + public bool Success => Outcome == SpendOutcome.Success; +} From be19c0ad8d8bee790a9b636bb429df6653bfabab Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 29 May 2026 13:29:19 -0400 Subject: [PATCH 03/19] feat(repo): cosmetic catalog id enumerations on ICollectionRepository --- .../Collectibles/CollectionRepository.cs | 12 ++++++++ .../Collectibles/ICollectionRepository.cs | 4 +++ .../Repositories/CollectionRepositoryTests.cs | 28 +++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 SVSim.UnitTests/Repositories/CollectionRepositoryTests.cs diff --git a/SVSim.Database/Repositories/Collectibles/CollectionRepository.cs b/SVSim.Database/Repositories/Collectibles/CollectionRepository.cs index c398aac..859d705 100644 --- a/SVSim.Database/Repositories/Collectibles/CollectionRepository.cs +++ b/SVSim.Database/Repositories/Collectibles/CollectionRepository.cs @@ -16,4 +16,16 @@ public class CollectionRepository : ICollectionRepository { return await _dbContext.Set().AsNoTracking().Include(skin => skin.Class).ToListAsync(); } + + public Task> GetAllSleeveIds() => + _dbContext.Set().AsNoTracking().Select(s => s.Id).ToListAsync(); + + public Task> GetAllEmblemIds() => + _dbContext.Set().AsNoTracking().Select(e => e.Id).ToListAsync(); + + public Task> GetAllDegreeIds() => + _dbContext.Set().AsNoTracking().Select(d => d.Id).ToListAsync(); + + public Task> GetAllMyPageBackgroundIds() => + _dbContext.Set().AsNoTracking().Select(m => m.Id).ToListAsync(); } \ No newline at end of file diff --git a/SVSim.Database/Repositories/Collectibles/ICollectionRepository.cs b/SVSim.Database/Repositories/Collectibles/ICollectionRepository.cs index 17e7dab..870102e 100644 --- a/SVSim.Database/Repositories/Collectibles/ICollectionRepository.cs +++ b/SVSim.Database/Repositories/Collectibles/ICollectionRepository.cs @@ -5,4 +5,8 @@ namespace SVSim.Database.Repositories.Collectibles; public interface ICollectionRepository { Task> GetLeaderSkins(); + Task> GetAllSleeveIds(); + Task> GetAllEmblemIds(); + Task> GetAllDegreeIds(); + Task> GetAllMyPageBackgroundIds(); } \ No newline at end of file diff --git a/SVSim.UnitTests/Repositories/CollectionRepositoryTests.cs b/SVSim.UnitTests/Repositories/CollectionRepositoryTests.cs new file mode 100644 index 0000000..2f853ca --- /dev/null +++ b/SVSim.UnitTests/Repositories/CollectionRepositoryTests.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Models; +using SVSim.Database.Repositories.Collectibles; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Repositories; + +public class CollectionRepositoryTests +{ + [Test] + public async Task GetAllSleeveIds_returns_every_master_sleeve() + { + using var factory = new SVSimTestFactory(); + await factory.SeedGlobalsAsync(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Sleeves.Add(new SleeveEntry { Id = 123456 }); + await db.SaveChangesAsync(); + + var repo = new CollectionRepository(db); + var ids = await repo.GetAllSleeveIds(); + + Assert.That(ids, Does.Contain(123456)); + Assert.That(ids.Count, Is.EqualTo(await db.Sleeves.CountAsync())); + } +} From 91c539fb8db7d0d9d66418629f041c3b528dbe5e Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 29 May 2026 13:40:24 -0400 Subject: [PATCH 04/19] feat(services): ViewerEntitlements (freeplay-aware ownership/balance authority) Co-Authored-By: Claude Sonnet 4.6 --- SVSim.Database/Services/ViewerEntitlements.cs | 107 ++++++++++++++++++ .../Services/ViewerEntitlementsTests.cs | 99 ++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 SVSim.Database/Services/ViewerEntitlements.cs create mode 100644 SVSim.UnitTests/Services/ViewerEntitlementsTests.cs 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); + } +} From 3bf9ad1c4234be385cc4072f22822353654f8b2b Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 29 May 2026 13:41:52 -0400 Subject: [PATCH 05/19] test(config): include Freeplay in exhaustive ConfigSection seed-count assertion --- SVSim.UnitTests/Models/GameConfigurationJsonbTests.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/SVSim.UnitTests/Models/GameConfigurationJsonbTests.cs b/SVSim.UnitTests/Models/GameConfigurationJsonbTests.cs index ea39563..7024e4d 100644 --- a/SVSim.UnitTests/Models/GameConfigurationJsonbTests.cs +++ b/SVSim.UnitTests/Models/GameConfigurationJsonbTests.cs @@ -25,12 +25,13 @@ public class GameConfigurationJsonbTests var rows = await db.GameConfigs.AsNoTracking().ToListAsync(); var byName = rows.ToDictionary(r => r.SectionName); - // One row per [ConfigSection]-marked POCO (9 sections today: Player, DefaultGrants, - // DefaultLoadout, Challenge, Rotation, PackRates, MyRotationSchedule, Story, ResourceConfig). + // One row per [ConfigSection]-marked POCO (10 sections today: Player, DefaultGrants, + // DefaultLoadout, Challenge, Rotation, PackRates, MyRotationSchedule, Story, ResourceConfig, + // Freeplay). Assert.That(byName.Keys, Is.EquivalentTo(new[] { "Player", "DefaultGrants", "DefaultLoadout", "Challenge", "Rotation", "PackRates", - "MyRotationSchedule", "Story", "ResourceConfig", + "MyRotationSchedule", "Story", "ResourceConfig", "Freeplay", })); var resources = JsonSerializer.Deserialize(byName["ResourceConfig"].ValueJson)!; From b7ee0cdcf814f3993cca347112a24be1e4b58a8d Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 29 May 2026 13:47:22 -0400 Subject: [PATCH 06/19] test(entitlements): cover EffectiveOwnedCards/Cosmetics; document include preconditions Co-Authored-By: Claude Sonnet 4.6 --- .../Services/IViewerEntitlements.cs | 8 + .../Services/ViewerEntitlementsTests.cs | 169 ++++++++++++++++++ 2 files changed, 177 insertions(+) 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"); + } + } } From 00523076865d3b367d3f8905029a2dd69a267b5a Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 29 May 2026 13:49:36 -0400 Subject: [PATCH 07/19] feat(services): CurrencySpendService (central debit primitive, freeplay-aware) --- .../Services/CurrencySpendService.cs | 51 +++++++++++ .../Services/CurrencySpendServiceTests.cs | 91 +++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 SVSim.Database/Services/CurrencySpendService.cs create mode 100644 SVSim.UnitTests/Services/CurrencySpendServiceTests.cs diff --git a/SVSim.Database/Services/CurrencySpendService.cs b/SVSim.Database/Services/CurrencySpendService.cs new file mode 100644 index 0000000..1d3ef5e --- /dev/null +++ b/SVSim.Database/Services/CurrencySpendService.cs @@ -0,0 +1,51 @@ +using SVSim.Database.Models; + +namespace SVSim.Database.Services; + +public class CurrencySpendService : ICurrencySpendService +{ + private readonly IViewerEntitlements _entitlements; + + public CurrencySpendService(IViewerEntitlements entitlements) => _entitlements = entitlements; + + public Task TrySpendAsync(Viewer viewer, SpendCurrency currency, long cost, CancellationToken ct = default) + { + if (cost < 0) cost = 0; + + // Freeplay bypass applies only to the three main currencies; SpotPoint always real. + if (_entitlements.IsFreeplay && currency != SpendCurrency.SpotPoint) + { + return Task.FromResult(new SpendResult( + SpendOutcome.Success, _entitlements.EffectiveBalance(viewer, currency))); + } + + ulong current = GetBalance(viewer, currency); + if (current < (ulong)cost) + return Task.FromResult(new SpendResult(SpendOutcome.Insufficient, (long)current)); + + ulong post = current - (ulong)cost; + SetBalance(viewer, currency, post); + return Task.FromResult(new SpendResult(SpendOutcome.Success, (long)post)); + } + + private static ulong GetBalance(Viewer v, SpendCurrency c) => c switch + { + SpendCurrency.Crystal => v.Currency.Crystals, + SpendCurrency.Rupee => v.Currency.Rupees, + SpendCurrency.RedEther => v.Currency.RedEther, + SpendCurrency.SpotPoint => v.Currency.SpotPoints, + _ => throw new ArgumentOutOfRangeException(nameof(c)), + }; + + private static void SetBalance(Viewer v, SpendCurrency c, ulong value) + { + switch (c) + { + case SpendCurrency.Crystal: v.Currency.Crystals = value; break; + case SpendCurrency.Rupee: v.Currency.Rupees = value; break; + case SpendCurrency.RedEther: v.Currency.RedEther = value; break; + case SpendCurrency.SpotPoint: v.Currency.SpotPoints = value; break; + default: throw new ArgumentOutOfRangeException(nameof(c)); + } + } +} diff --git a/SVSim.UnitTests/Services/CurrencySpendServiceTests.cs b/SVSim.UnitTests/Services/CurrencySpendServiceTests.cs new file mode 100644 index 0000000..f506b66 --- /dev/null +++ b/SVSim.UnitTests/Services/CurrencySpendServiceTests.cs @@ -0,0 +1,91 @@ +using SVSim.Database.Enums; +using SVSim.Database.Models; +using SVSim.Database.Services; + +namespace SVSim.UnitTests.Services; + +public class CurrencySpendServiceTests +{ + private sealed class FakeEntitlements : IViewerEntitlements + { + public bool IsFreeplay { get; init; } + public long FreeplayAmount { get; init; } = 99999; + + public long EffectiveBalance(Viewer viewer, SpendCurrency currency) + { + if (IsFreeplay && currency != SpendCurrency.SpotPoint) return FreeplayAmount; + 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, + _ => 0, + }; + } + public bool OwnsCard(Viewer viewer, long cardId) => IsFreeplay; + public bool OwnsCosmetic(Viewer viewer, CosmeticType type, int id) => IsFreeplay; + public Task> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default) + => Task.FromResult>(new List()); + public Task EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default) + => throw new NotSupportedException(); + } + + private static Viewer NewViewer() => new() { Currency = new ViewerCurrency() }; + + [Test] + public async Task Normal_deducts_and_returns_post_state() + { + var v = NewViewer(); + v.Currency.Crystals = 250; + var svc = new CurrencySpendService(new FakeEntitlements { IsFreeplay = false }); + + var r = await svc.TrySpendAsync(v, SpendCurrency.Crystal, 100); + + Assert.That(r.Success, Is.True); + Assert.That(r.PostStateTotal, Is.EqualTo(150)); + Assert.That(v.Currency.Crystals, Is.EqualTo(150UL)); + } + + [Test] + public async Task Normal_insufficient_does_not_deduct() + { + var v = NewViewer(); + v.Currency.Rupees = 50; + var svc = new CurrencySpendService(new FakeEntitlements { IsFreeplay = false }); + + var r = await svc.TrySpendAsync(v, SpendCurrency.Rupee, 100); + + Assert.That(r.Success, Is.False); + Assert.That(r.Outcome, Is.EqualTo(SpendOutcome.Insufficient)); + Assert.That(v.Currency.Rupees, Is.EqualTo(50UL), "no deduction on insufficient funds"); + } + + [Test] + public async Task Freeplay_main_currency_succeeds_without_deducting() + { + var v = NewViewer(); + v.Currency.Crystals = 10; + var svc = new CurrencySpendService(new FakeEntitlements { IsFreeplay = true }); + + var r = await svc.TrySpendAsync(v, SpendCurrency.Crystal, 100000); + + Assert.That(r.Success, Is.True, "freeplay never blocks on affordability"); + Assert.That(r.PostStateTotal, Is.EqualTo(99999), "post-state shows the freeplay balance"); + Assert.That(v.Currency.Crystals, Is.EqualTo(10UL), "DB balance untouched in freeplay"); + } + + [Test] + public async Task Freeplay_spot_points_still_deduct() + { + var v = NewViewer(); + v.Currency.SpotPoints = 300; + var svc = new CurrencySpendService(new FakeEntitlements { IsFreeplay = true }); + + var r = await svc.TrySpendAsync(v, SpendCurrency.SpotPoint, 100); + + Assert.That(r.Success, Is.True); + Assert.That(r.PostStateTotal, Is.EqualTo(200)); + Assert.That(v.Currency.SpotPoints, Is.EqualTo(200UL), "spot points are real even in freeplay"); + } +} From d560f9ade4ac28c1136f67830a8b650edcf0b91f Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 29 May 2026 13:55:46 -0400 Subject: [PATCH 08/19] chore(di): register entitlements + spend services; add test freeplay helper Co-Authored-By: Claude Sonnet 4.6 --- SVSim.EmulatedEntrypoint/Program.cs | 2 ++ .../Infrastructure/SVSimTestFactory.cs | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index 43391b8..524916c 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -84,6 +84,8 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped await new PackImporter().ImportAsync(ctx, seedDir); } + /// + /// Enables Freeplay mode by writing the GameConfigs DB row (tier-1 of GameConfigService). + /// Call before issuing the request under test. Idempotent. + /// + public async Task EnableFreeplayAsync(ulong currencyAmount = 99999, int cardCopies = 3) + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var json = System.Text.Json.JsonSerializer.Serialize(new + { + Enabled = true, + CurrencyAmount = currencyAmount, + CardCopies = cardCopies, + }); + var existing = await db.GameConfigs.FirstOrDefaultAsync(s => s.SectionName == "Freeplay"); + if (existing is null) + db.GameConfigs.Add(new GameConfigSection { SectionName = "Freeplay", ValueJson = json }); + else + existing.ValueJson = json; + await db.SaveChangesAsync(); + } + /// Convenience: bake the X-Test-Viewer-Id header into a fresh client. public HttpClient CreateAuthenticatedClient(long viewerId) { From 092176ea1a679c2d00e4ac1123e45c934a5bf2be Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 29 May 2026 14:03:35 -0400 Subject: [PATCH 09/19] feat(load): project currency/cards/cosmetics through entitlements (freeplay) Route /load/index currency, owned-card list, and cosmetic id-lists through IViewerEntitlements so freeplay mode inflates all three without touching the viewer's DB state. Adds LoadControllerFreeplayTests (2 tests). Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/LoadController.cs | 41 ++++++------ .../LoadControllerFreeplayTests.cs | 63 +++++++++++++++++++ 2 files changed, 83 insertions(+), 21 deletions(-) create mode 100644 SVSim.UnitTests/Controllers/LoadControllerFreeplayTests.cs diff --git a/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs b/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs index 7b10a6f..01d99ac 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs @@ -50,12 +50,13 @@ public class LoadController : SVSimController private readonly IBattlePassService _battlePass; private readonly IViewerMissionStateService _missionState; private readonly SVSimDbContext _db; + private readonly IViewerEntitlements _entitlements; public LoadController(IViewerRepository viewerRepository, ICardRepository cardRepository, ICollectionRepository collectionRepository, IGlobalsRepository globalsRepository, ICardAcquisitionService acquisition, IGameConfigService config, IBattlePassService battlePass, IViewerMissionStateService missionState, - SVSimDbContext db) + SVSimDbContext db, IViewerEntitlements entitlements) { _viewerRepository = viewerRepository; _cardRepository = cardRepository; @@ -66,6 +67,7 @@ public class LoadController : SVSimController _battlePass = battlePass; _missionState = missionState; _db = db; + _entitlements = entitlements; } [HttpPost("index")] @@ -127,20 +129,11 @@ public class LoadController : SVSimController // * card_set_id=90000 (engine tokens, char_type=4): never collectible // Both naturally fall out of "ownership-only" since the viewer can't own them; // re-confirm the filter if we later move to Option B and start iterating card-sets. - var defaultCards = await _cardRepository.GetDefaultCards(); - var defaultCardIds = defaultCards.Select(c => c.Id).ToHashSet(); - var ownedCollectibles = viewer.Cards - .Where(c => c.Count > 0 && !defaultCardIds.Contains(c.Card.Id)); - var allCardsAsOwned = ownedCollectibles - .Concat(defaultCards.Select(bc => new OwnedCardEntry - { - Card = bc, - Count = 3, - IsProtected = true - })) - .ToList(); + // Owned-card projection (incl. the freeplay "all cards" path) lives in the entitlements + // service so both modes share one definition. + var allCardsAsOwned = await _entitlements.EffectiveOwnedCardsAsync(viewer, ct); - List allLeaderSkins = await _collectionRepository.GetLeaderSkins(); + var cosmetics = await _entitlements.EffectiveCosmeticsAsync(viewer, ct); var classExpCurve = await _globalsRepository.GetClassExpCurve(); List classExps = new(); @@ -179,7 +172,13 @@ public class LoadController : SVSimController { UserTutorial = new UserTutorial { TutorialStep = viewer.MissionData.TutorialState }, UserInfo = new UserInfo(deviceType, viewer), - UserCurrency = new UserCurrency(viewer), + UserCurrency = new UserCurrency(viewer) + { + Crystals = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.Crystal), + TotalCrystals = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.Crystal), + Rupees = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.Rupee), + RedEther = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.RedEther), + }, UserItems = viewer.Items.Select(item => new UserItem(item)).ToList(), SpotPoint = checked((int)viewer.Currency.SpotPoints), UserRotationDecks = new UserFormatDeckInfo @@ -199,13 +198,13 @@ public class LoadController : SVSimController }, UserCards = allCardsAsOwned.Select(card => new UserCard(card)).ToList(), UserClasses = viewer.Classes.Select(vc => new UserClass(vc)).ToList(), - Sleeves = viewer.Sleeves.Select(s => new SleeveIdentifier { SleeveId = s.Id }).ToList(), - UserEmblems = viewer.Emblems.Select(e => new EmblemIdentifier { EmblemId = e.Id }).ToList(), - UserDegrees = viewer.Degrees.Select(d => new DegreeIdentifier { DegreeId = d.Id }).ToList(), - LeaderSkins = allLeaderSkins - .Select(skin => new UserLeaderSkin(skin, viewer.LeaderSkins.Any(vs => vs.Id == skin.Id))) + Sleeves = cosmetics.SleeveIds.Select(id => new SleeveIdentifier { SleeveId = id }).ToList(), + UserEmblems = cosmetics.EmblemIds.Select(id => new EmblemIdentifier { EmblemId = id }).ToList(), + UserDegrees = cosmetics.DegreeIds.Select(id => new DegreeIdentifier { DegreeId = id }).ToList(), + LeaderSkins = cosmetics.AllLeaderSkins + .Select(skin => new UserLeaderSkin(skin, cosmetics.OwnedLeaderSkinIds.Contains(skin.Id))) .ToList(), - MyPageBackgrounds = viewer.MyPageBackgrounds.Select(mpbg => mpbg.Id.ToString()).ToList(), + MyPageBackgrounds = cosmetics.MyPageBackgroundIds.Select(id => id.ToString()).ToList(), LootBoxRegulations = new LootBoxRegulations(), GatheringInfo = new GatheringInfo(), IsBattlePassPeriod = rotation.IsBattlePassPeriod, diff --git a/SVSim.UnitTests/Controllers/LoadControllerFreeplayTests.cs b/SVSim.UnitTests/Controllers/LoadControllerFreeplayTests.cs new file mode 100644 index 0000000..2d7afe4 --- /dev/null +++ b/SVSim.UnitTests/Controllers/LoadControllerFreeplayTests.cs @@ -0,0 +1,63 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Controllers; + +public class LoadControllerFreeplayTests +{ + private static StringContent Body() => new( + """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","carrier":"steam","card_master_hash":""}""", + Encoding.UTF8, "application/json"); + + [Test] + public async Task LoadIndex_freeplay_on_inflates_currency_and_grants_all_cards() + { + using var factory = new SVSimTestFactory(); + await factory.SeedGlobalsAsync(); + long viewerId = await factory.SeedViewerAsync(); + // Seed one collectible card so EffectiveOwnedCardsAsync has at least one entry + // (the minimal test set has no CollectionInfo rows — those cards are non-collectible). + await factory.SeedOwnedCardAsync(viewerId, 50001001L, count: 1); + await factory.EnableFreeplayAsync(); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var resp = await client.PostAsync("/load/index", Body()); + var json = await resp.Content.ReadAsStringAsync(); + Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK), json); + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + // Wire key for UserCurrency is "user_crystal_count" (IndexResponse.[JsonPropertyName("user_crystal_count")]) + Assert.That(root.GetProperty("user_crystal_count").GetProperty("crystal").GetUInt64(), Is.EqualTo(99999UL)); + Assert.That(root.GetProperty("user_crystal_count").GetProperty("rupy").GetUInt64(), Is.EqualTo(99999UL)); + Assert.That(root.GetProperty("user_crystal_count").GetProperty("red_ether").GetUInt64(), Is.EqualTo(99999UL)); + + var cards = root.GetProperty("user_card_list"); + Assert.That(cards.GetArrayLength(), Is.GreaterThan(0)); + // Wire key for card count is "number" (UserCard.[JsonPropertyName("number")]) + for (int i = 0; i < cards.GetArrayLength(); i++) + Assert.That(cards[i].GetProperty("number").GetInt32(), Is.EqualTo(3)); + } + + [Test] + public async Task LoadIndex_freeplay_off_unchanged_baseline() + { + using var factory = new SVSimTestFactory(); + await factory.SeedGlobalsAsync(); + long viewerId = await factory.SeedViewerAsync(); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var resp = await client.PostAsync("/load/index", Body()); + var json = await resp.Content.ReadAsStringAsync(); + Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK), json); + + using var doc = JsonDocument.Parse(json); + Assert.That(doc.RootElement.GetProperty("user_crystal_count").GetProperty("crystal").GetUInt64(), + Is.Not.EqualTo(99999UL), "freeplay off must not inflate currency"); + } +} From a3a49077b5b677931b337da099dcb6039c3ded8b Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 29 May 2026 14:07:30 -0400 Subject: [PATCH 10/19] refactor(load): drop now-dead card/collection repo deps after entitlements move Co-Authored-By: Claude Sonnet 4.6 --- SVSim.EmulatedEntrypoint/Controllers/LoadController.cs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs b/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs index 01d99ac..d9feae1 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs @@ -6,8 +6,6 @@ using SVSim.Database.Models; using SVSim.Database.Models.Config; using PreReleaseInfoEntity = SVSim.Database.Models.PreReleaseInfo; using PreReleaseInfoDto = SVSim.EmulatedEntrypoint.Models.Dtos.PreReleaseInfo; -using SVSim.Database.Repositories.Card; -using SVSim.Database.Repositories.Collectibles; using SVSim.Database.Repositories.Globals; using SVSim.Database.Repositories.Viewer; using SVSim.Database.Services; @@ -42,8 +40,6 @@ public class LoadController : SVSimController }; private readonly IViewerRepository _viewerRepository; - private readonly ICardRepository _cardRepository; - private readonly ICollectionRepository _collectionRepository; private readonly IGlobalsRepository _globalsRepository; private readonly ICardAcquisitionService _acquisition; private readonly IGameConfigService _config; @@ -52,15 +48,12 @@ public class LoadController : SVSimController private readonly SVSimDbContext _db; private readonly IViewerEntitlements _entitlements; - public LoadController(IViewerRepository viewerRepository, ICardRepository cardRepository, - ICollectionRepository collectionRepository, IGlobalsRepository globalsRepository, + public LoadController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository, ICardAcquisitionService acquisition, IGameConfigService config, IBattlePassService battlePass, IViewerMissionStateService missionState, SVSimDbContext db, IViewerEntitlements entitlements) { _viewerRepository = viewerRepository; - _cardRepository = cardRepository; - _collectionRepository = collectionRepository; _globalsRepository = globalsRepository; _acquisition = acquisition; _config = config; From 163299504aa1bd1d698b37fb7e11b2bef376b998 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 29 May 2026 14:10:50 -0400 Subject: [PATCH 11/19] refactor(pack): route currency spend through CurrencySpendService (freeplay) --- .../Controllers/PackController.cs | 35 ++++++++++--------- .../Controllers/PackControllerOpenTests.cs | 19 ++++++++++ 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs index 98f7984..1f97f0c 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs @@ -9,6 +9,7 @@ using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Pack; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Pack; +using SVSim.Database.Services; using SVSim.EmulatedEntrypoint.Services; namespace SVSim.EmulatedEntrypoint.Controllers; @@ -29,6 +30,8 @@ public class PackController : SVSimController private readonly SVSimDbContext _db; private readonly ICardAcquisitionService _acquisition; private readonly IGachaPointService _gachaPoint; + private readonly ICurrencySpendService _spend; + private readonly IViewerEntitlements _entitlements; public PackController( IPackRepository packs, @@ -37,7 +40,9 @@ public class PackController : SVSimController IRandom rng, SVSimDbContext db, ICardAcquisitionService acquisition, - IGachaPointService gachaPoint) + IGachaPointService gachaPoint, + ICurrencySpendService spend, + IViewerEntitlements entitlements) { _packs = packs; _opener = opener; @@ -46,6 +51,8 @@ public class PackController : SVSimController _db = db; _acquisition = acquisition; _gachaPoint = gachaPoint; + _spend = spend; + _entitlements = entitlements; } [HttpPost("info")] @@ -292,18 +299,16 @@ public class PackController : SVSimController { case 2: // CRYSTAL_MULTI { - ulong cost = (ulong)child.Cost * (ulong)packNumber; - if (viewer.Currency.Crystals < cost) - return BadRequest(new { error = "insufficient_crystals" }); - viewer.Currency.Crystals -= cost; + long cost = (long)child.Cost * packNumber; + var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, cost); + if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); break; } case 7: // RUPY_MULTI { - ulong cost = (ulong)child.Cost * (ulong)packNumber; - if (viewer.Currency.Rupees < cost) - return BadRequest(new { error = "insufficient_rupees" }); - viewer.Currency.Rupees -= cost; + long cost = (long)child.Cost * packNumber; + var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, cost); + if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); break; } case 3: // DAILY single — once per UTC day @@ -315,10 +320,9 @@ public class PackController : SVSimController if (existing?.LastDailyFreeAt is DateTime last && last.Date == now.Date) return BadRequest(new { error = "daily_free_already_claimed" }); - ulong cost = (ulong)child.Cost * (ulong)packNumber; - if (cost > 0 && viewer.Currency.Rupees < cost) - return BadRequest(new { error = "insufficient_rupees" }); - if (cost > 0) viewer.Currency.Rupees -= cost; + long cost = (long)child.Cost * packNumber; + var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, cost); + if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); break; } } @@ -359,14 +363,13 @@ public class PackController : SVSimController // Currency reward entries only apply to purchasable packs; tutorial path omits them. if (!isTutorialPath) { - var postViewer = await _db.Viewers.FirstAsync(v => v.Id == viewerId); if (child.TypeDetail == 2) { - rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)postViewer.Currency.Crystals }); + rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)_entitlements.EffectiveBalance(viewer, SpendCurrency.Crystal) }); } else if (child.TypeDetail == 7 || child.TypeDetail == 3) { - rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)postViewer.Currency.Rupees }); + rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)_entitlements.EffectiveBalance(viewer, SpendCurrency.Rupee) }); } } rewardList.AddRange(grant.RewardList); diff --git a/SVSim.UnitTests/Controllers/PackControllerOpenTests.cs b/SVSim.UnitTests/Controllers/PackControllerOpenTests.cs index 5c2f2b2..f0ca05d 100644 --- a/SVSim.UnitTests/Controllers/PackControllerOpenTests.cs +++ b/SVSim.UnitTests/Controllers/PackControllerOpenTests.cs @@ -524,6 +524,25 @@ public class PackControllerOpenTests Assert.That(v.GachaPointBalances.Single().Points, Is.EqualTo(3)); } + [Test] + public async Task Open_freeplay_succeeds_with_zero_balance_and_no_deduction() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedOpenablePack(factory, viewerId, rupees: 0); // broke, but freeplay + await factory.EnableFreeplayAsync(); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}"""; + var response = await client.PostAsync("/pack/open", JsonBody(json)); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), await response.Content.ReadAsStringAsync()); + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var v = await db.Viewers.FirstAsync(x => x.Id == viewerId); + Assert.That(v.Currency.Rupees, Is.EqualTo(0UL), "freeplay must not deduct real DB balance"); + } + [Test] public async Task TutorialPackOpen_does_not_accrue_gacha_points() { From 2e021c8b9e3fc2fbeeff1150880e4d0a3c7f9672 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 29 May 2026 14:12:43 -0400 Subject: [PATCH 12/19] refactor(builddeck): route currency spend through CurrencySpendService Inject ICurrencySpendService and replace the inline crystal/rupee debit block in BuildDeckController.Buy with TrySpendAsync calls, so freeplay mode gets the no-deduct path automatically. All 18 BuildDeckController tests pass unchanged. Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/BuildDeckController.cs | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/BuildDeckController.cs b/SVSim.EmulatedEntrypoint/Controllers/BuildDeckController.cs index 5fb3075..01e12df 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/BuildDeckController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/BuildDeckController.cs @@ -22,15 +22,18 @@ public class BuildDeckController : SVSimController private readonly IBuildDeckRepository _repo; private readonly SVSimDbContext _db; private readonly RewardGrantService _rewards; + private readonly ICurrencySpendService _spend; public BuildDeckController( IBuildDeckRepository repo, SVSimDbContext db, - RewardGrantService rewards) + RewardGrantService rewards, + ICurrencySpendService spend) { _repo = repo; _db = db; _rewards = rewards; + _spend = spend; } /// @@ -200,19 +203,15 @@ public class BuildDeckController : SVSimController // Debit + post-state currency entry if (request.SalesType == 1) { - ulong cost = (ulong)priceCrystal!.Value; - if (viewer.Currency.Crystals < cost) - return BadRequest(new { error = "insufficient_crystals" }); - viewer.Currency.Crystals -= cost; - rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)viewer.Currency.Crystals }); + var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, priceCrystal!.Value); + if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); + rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)r.PostStateTotal }); } else if (request.SalesType == 2) { - ulong cost = (ulong)priceRupy!.Value; - if (viewer.Currency.Rupees < cost) - return BadRequest(new { error = "insufficient_rupees" }); - viewer.Currency.Rupees -= cost; - rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)viewer.Currency.Rupees }); + var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, priceRupy!.Value); + if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); + rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)r.PostStateTotal }); } // sales_type == 0 (free): no debit, no currency entry From 1f584613260a5f1176f34b93f4cf15ea8cbfa2a2 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 29 May 2026 14:14:14 -0400 Subject: [PATCH 13/19] refactor(sleeve): route currency spend through CurrencySpendService Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/SleeveController.cs | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/SleeveController.cs b/SVSim.EmulatedEntrypoint/Controllers/SleeveController.cs index c9d623e..1f66091 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/SleeveController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/SleeveController.cs @@ -20,11 +20,13 @@ public class SleeveController : SVSimController { private readonly SVSimDbContext _db; private readonly RewardGrantService _rewards; + private readonly ICurrencySpendService _spend; - public SleeveController(SVSimDbContext db, RewardGrantService rewards) + public SleeveController(SVSimDbContext db, RewardGrantService rewards, ICurrencySpendService spend) { _db = db; _rewards = rewards; + _spend = spend; } [HttpPost("info")] @@ -122,20 +124,16 @@ public class SleeveController : SVSimController case 1: // crystal if (product.PriceCrystal is null) return BadRequest(new { error = "price_not_available_for_currency" }); - var crystalCost = (ulong)product.PriceCrystal.Value; - if (viewer.Currency.Crystals < crystalCost) - return BadRequest(new { error = "insufficient_crystals" }); - viewer.Currency.Crystals -= crystalCost; - rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)viewer.Currency.Crystals }); + var crystalRes = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, product.PriceCrystal.Value); + if (!crystalRes.Success) return BadRequest(new { error = "insufficient_crystals" }); + rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)crystalRes.PostStateTotal }); break; case 2: // rupy if (product.PriceRupy is null) return BadRequest(new { error = "price_not_available_for_currency" }); - var rupyCost = (ulong)product.PriceRupy.Value; - if (viewer.Currency.Rupees < rupyCost) - return BadRequest(new { error = "insufficient_rupees" }); - viewer.Currency.Rupees -= rupyCost; - rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)viewer.Currency.Rupees }); + var rupyRes = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, product.PriceRupy.Value); + if (!rupyRes.Success) return BadRequest(new { error = "insufficient_rupees" }); + rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)rupyRes.PostStateTotal }); break; } From fb257a544f1930da1f01208cb00353155a4783ca Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 29 May 2026 14:17:01 -0400 Subject: [PATCH 14/19] refactor(leaderskin): route currency spend through CurrencySpendService Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/LeaderSkinController.cs | 78 ++++++++++--------- 1 file changed, 43 insertions(+), 35 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/LeaderSkinController.cs b/SVSim.EmulatedEntrypoint/Controllers/LeaderSkinController.cs index a85850a..2c1baa9 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/LeaderSkinController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/LeaderSkinController.cs @@ -30,12 +30,14 @@ public class LeaderSkinController : SVSimController private readonly SVSimDbContext _db; private readonly RewardGrantService _rewards; private readonly TimeProvider _time; + private readonly ICurrencySpendService _spend; - public LeaderSkinController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time) + public LeaderSkinController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time, ICurrencySpendService spend) { _db = db; _rewards = rewards; _time = time; + _spend = spend; } [HttpPost("set")] @@ -175,7 +177,7 @@ public class LeaderSkinController : SVSimController return BadRequest(new { error = "already_purchased" }); var rewardList = new List(); - var debit = DebitProductPrice(viewer, product, request.SalesType); + var debit = await DebitProductPrice(viewer, product, request.SalesType); if (debit.Error is not null) return BadRequest(new { error = debit.Error }); if (debit.PostState is not null) rewardList.Add(debit.PostState); @@ -206,7 +208,7 @@ public class LeaderSkinController : SVSimController var viewer = await LoadViewerGraphAsync(viewerId); var rewardList = new List(); - var debit = DebitSetPrice(viewer, series, request.SalesType); + var debit = await DebitSetPrice(viewer, series, request.SalesType); if (debit.Error is not null) return BadRequest(new { error = debit.Error }); if (debit.PostState is not null) rewardList.Add(debit.PostState); @@ -332,52 +334,58 @@ public class LeaderSkinController : SVSimController return false; } - private (RewardListEntry? PostState, string? Error) DebitProductPrice( + private async Task<(RewardListEntry? PostState, string? Error)> DebitProductPrice( Viewer viewer, LeaderSkinShopProductEntry product, int salesType) { - return salesType switch + switch (salesType) { - 0 when product.SinglePriceCrystal == 0 && product.SinglePriceRupy == 0 => (null, null), - 0 => (null, "price_not_available_for_currency"), - 1 => product.SinglePriceCrystal is null - ? (null, "price_not_available_for_currency") - : DebitCrystal(viewer, product.SinglePriceCrystal.Value), - 2 => product.SinglePriceRupy is null - ? (null, "price_not_available_for_currency") - : DebitRupy(viewer, product.SinglePriceRupy.Value), - _ => (null, "invalid_sales_type"), - }; + case 0 when product.SinglePriceCrystal == 0 && product.SinglePriceRupy == 0: + return (null, null); + case 0: + return (null, "price_not_available_for_currency"); + case 1: + if (product.SinglePriceCrystal is null) return (null, "price_not_available_for_currency"); + return await DebitCrystal(viewer, product.SinglePriceCrystal.Value); + case 2: + if (product.SinglePriceRupy is null) return (null, "price_not_available_for_currency"); + return await DebitRupy(viewer, product.SinglePriceRupy.Value); + default: + return (null, "invalid_sales_type"); + } } - private (RewardListEntry? PostState, string? Error) DebitSetPrice( + private async Task<(RewardListEntry? PostState, string? Error)> DebitSetPrice( Viewer viewer, LeaderSkinShopSeriesEntry series, int salesType) { - return salesType switch + switch (salesType) { - 0 when series.SetPriceCrystal == 0 && series.SetPriceRupy == 0 => (null, null), - 0 => (null, "price_not_available_for_currency"), - 1 => series.SetPriceCrystal is null - ? (null, "price_not_available_for_currency") - : DebitCrystal(viewer, series.SetPriceCrystal.Value), - 2 => series.SetPriceRupy is null - ? (null, "price_not_available_for_currency") - : DebitRupy(viewer, series.SetPriceRupy.Value), - _ => (null, "invalid_sales_type"), - }; + case 0 when series.SetPriceCrystal == 0 && series.SetPriceRupy == 0: + return (null, null); + case 0: + return (null, "price_not_available_for_currency"); + case 1: + if (series.SetPriceCrystal is null) return (null, "price_not_available_for_currency"); + return await DebitCrystal(viewer, series.SetPriceCrystal.Value); + case 2: + if (series.SetPriceRupy is null) return (null, "price_not_available_for_currency"); + return await DebitRupy(viewer, series.SetPriceRupy.Value); + default: + return (null, "invalid_sales_type"); + } } - private static (RewardListEntry?, string?) DebitCrystal(Viewer viewer, int amount) + private async Task<(RewardListEntry?, string?)> DebitCrystal(Viewer viewer, int amount) { - if (viewer.Currency.Crystals < (ulong)amount) return (null, "insufficient_crystals"); - viewer.Currency.Crystals -= (ulong)amount; - return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)viewer.Currency.Crystals }, null); + var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, amount); + if (!r.Success) return (null, "insufficient_crystals"); + return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null); } - private static (RewardListEntry?, string?) DebitRupy(Viewer viewer, int amount) + private async Task<(RewardListEntry?, string?)> DebitRupy(Viewer viewer, int amount) { - if (viewer.Currency.Rupees < (ulong)amount) return (null, "insufficient_rupees"); - viewer.Currency.Rupees -= (ulong)amount; - return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)viewer.Currency.Rupees }, null); + var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, amount); + if (!r.Success) return (null, "insufficient_rupees"); + return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null); } private async Task ApplyRewardsAsync( From 5c6b70327617ec73e40bfef907618c98c8485d7a Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 29 May 2026 14:18:57 -0400 Subject: [PATCH 15/19] refactor(itempurchase): route currency spend (not items) through CurrencySpendService Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/ItemPurchaseController.cs | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/ItemPurchaseController.cs b/SVSim.EmulatedEntrypoint/Controllers/ItemPurchaseController.cs index 3510c5c..7b1d3b4 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/ItemPurchaseController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/ItemPurchaseController.cs @@ -23,12 +23,14 @@ public class ItemPurchaseController : SVSimController private readonly SVSimDbContext _db; private readonly RewardGrantService _rewards; private readonly TimeProvider _time; + private readonly ICurrencySpendService _spend; - public ItemPurchaseController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time) + public ItemPurchaseController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time, ICurrencySpendService spend) { _db = db; _rewards = rewards; _time = time; + _spend = spend; } [HttpPost("info")] @@ -117,7 +119,7 @@ public class ItemPurchaseController : SVSimController var rewardList = new List(); // Debit the require side. RewardGrantService is grant-only, so handle this inline. - var debit = TryDebit(viewer, (UserGoodsType)entry.RequireItemType, entry.RequireItemId, entry.RequireItemNum); + var debit = await TryDebit(viewer, (UserGoodsType)entry.RequireItemType, entry.RequireItemId, entry.RequireItemNum); if (debit.Error is not null) return BadRequest(new { error = debit.Error }); if (debit.PostState is not null) rewardList.Add(debit.PostState); @@ -160,29 +162,29 @@ public class ItemPurchaseController : SVSimController /// from the viewer, returning a post-state-aware the client /// uses to refresh its cached count. Returns an error string on insufficient balance. /// - private static (RewardListEntry? PostState, string? Error) TryDebit( + private async Task<(RewardListEntry? PostState, string? Error)> TryDebit( Viewer viewer, UserGoodsType type, long detailId, int num) { switch (type) { case UserGoodsType.RedEther: - if (viewer.Currency.RedEther < (ulong)num) - return (null, "insufficient_red_ether"); - viewer.Currency.RedEther -= (ulong)num; - return (new RewardListEntry { RewardType = 1, RewardId = 0, RewardNum = (int)viewer.Currency.RedEther }, null); - + { + var r = await _spend.TrySpendAsync(viewer, SpendCurrency.RedEther, num); + if (!r.Success) return (null, "insufficient_red_ether"); + return (new RewardListEntry { RewardType = 1, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null); + } case UserGoodsType.Crystal: - if (viewer.Currency.Crystals < (ulong)num) - return (null, "insufficient_crystals"); - viewer.Currency.Crystals -= (ulong)num; - return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)viewer.Currency.Crystals }, null); - + { + var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, num); + if (!r.Success) return (null, "insufficient_crystals"); + return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null); + } case UserGoodsType.Rupy: - if (viewer.Currency.Rupees < (ulong)num) - return (null, "insufficient_rupees"); - viewer.Currency.Rupees -= (ulong)num; - return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)viewer.Currency.Rupees }, null); - + { + var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, num); + if (!r.Success) return (null, "insufficient_rupees"); + return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null); + } case UserGoodsType.Item: var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId); if (owned is null || owned.Count < num) From ee407befb58032b5474c42b904e58c465ce43728 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 29 May 2026 14:20:32 -0400 Subject: [PATCH 16/19] refactor(spotcard): centralize spot-point spend via CurrencySpendService Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/SpotCardExchangeController.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/SpotCardExchangeController.cs b/SVSim.EmulatedEntrypoint/Controllers/SpotCardExchangeController.cs index 584cbc7..d5d8ec9 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/SpotCardExchangeController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/SpotCardExchangeController.cs @@ -30,12 +30,14 @@ public class SpotCardExchangeController : SVSimController private readonly SVSimDbContext _db; private readonly RewardGrantService _rewards; private readonly TimeProvider _time; + private readonly ICurrencySpendService _spend; - public SpotCardExchangeController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time) + public SpotCardExchangeController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time, ICurrencySpendService spend) { _db = db; _rewards = rewards; _time = time; + _spend = spend; } [HttpPost("top")] @@ -131,14 +133,14 @@ public class SpotCardExchangeController : SVSimController // Debit spot points. Client-supplied exchange_point isn't authoritative — server uses // catalog price. Mirroring the build_deck/sleeve convention: post-state currency entry // first, then grants. - if (viewer.Currency.SpotPoints < (ulong)entry.ExchangePoint) + var spotRes = await _spend.TrySpendAsync(viewer, SpendCurrency.SpotPoint, entry.ExchangePoint); + if (!spotRes.Success) return BadRequest(new { error = "insufficient_spot_points" }); - viewer.Currency.SpotPoints -= (ulong)entry.ExchangePoint; rewardList.Add(new RewardListEntry { RewardType = (int)UserGoodsType.SpotCardPoint, RewardId = 0, - RewardNum = checked((int)viewer.Currency.SpotPoints), + RewardNum = checked((int)spotRes.PostStateTotal), }); // Grant the card itself via the existing card dispatcher (handles cosmetic cascade). From d68a85bbc54272273c6c5219a80ad2d0b9b6bc57 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 29 May 2026 14:23:50 -0400 Subject: [PATCH 17/19] refactor(battlepass): route premium-buy crystal spend through CurrencySpendService Co-Authored-By: Claude Sonnet 4.6 --- .../Services/BattlePassService.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs b/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs index 0a10e66..2a07fcc 100644 --- a/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs +++ b/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs @@ -23,19 +23,22 @@ public sealed class BattlePassService : IBattlePassService private readonly TimeProvider _time; private readonly SVSimDbContext _db; private readonly RewardGrantService _rewards; + private readonly ICurrencySpendService _spend; public BattlePassService( IBattlePassRepository bp, IViewerBattlePassRepository viewerBp, TimeProvider time, SVSimDbContext db, - RewardGrantService rewards) + RewardGrantService rewards, + ICurrencySpendService spend) { _bp = bp; _viewerBp = viewerBp; _time = time; _db = db; _rewards = rewards; + _spend = spend; } public async Task?> GetLevelCurveAsync(CancellationToken ct) @@ -166,13 +169,13 @@ public sealed class BattlePassService : IBattlePassService if (progress.IsPremium) return new BattlePassBuyOutcome(23, Array.Empty(), Array.Empty()); - if (viewer.Currency.Crystals < (ulong)season.PriceCrystal) + var spendResult = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, season.PriceCrystal, ct); + if (!spendResult.Success) return new BattlePassBuyOutcome(22, Array.Empty(), Array.Empty()); // BeginTransactionAsync is a no-op on the SQLite in-memory test DB but is safe to call. await using var tx = await _db.Database.BeginTransactionAsync(ct); - viewer.Currency.Crystals -= (ulong)season.PriceCrystal; progress.IsPremium = true; // Retroactive grants: every premium reward at level <= current_level not already claimed. @@ -206,7 +209,7 @@ public sealed class BattlePassService : IBattlePassService // append the post-deduction total so the client gets the correct final balance. postState.RemoveAll(r => r.RewardType == (int)UserGoodsType.Crystal); postState.Add(new GrantedReward( - (int)UserGoodsType.Crystal, 0, (int)viewer.Currency.Crystals)); + (int)UserGoodsType.Crystal, 0, (int)spendResult.PostStateTotal)); return new BattlePassBuyOutcome(1, achieved, postState); } From 302bf17c310092b9cfed4df455e29b7518c1e477 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 29 May 2026 14:36:50 -0400 Subject: [PATCH 18/19] feat(cosmetics): route ownership checks + shop owned-flags through entitlements (freeplay) Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/LeaderSkinController.cs | 30 ++++++++++++++----- .../Controllers/SleeveController.cs | 20 +++++++++---- .../Controllers/LeaderSkinControllerTests.cs | 23 ++++++++++++++ 3 files changed, 61 insertions(+), 12 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/LeaderSkinController.cs b/SVSim.EmulatedEntrypoint/Controllers/LeaderSkinController.cs index 2c1baa9..33239d3 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/LeaderSkinController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/LeaderSkinController.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore; using SVSim.Database; using SVSim.Database.Enums; using SVSim.Database.Models; +using SVSim.Database.Repositories.Collectibles; using SVSim.Database.Services; using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; @@ -31,13 +32,17 @@ public class LeaderSkinController : SVSimController private readonly RewardGrantService _rewards; private readonly TimeProvider _time; private readonly ICurrencySpendService _spend; + private readonly IViewerEntitlements _entitlements; + private readonly ICollectionRepository _collection; - public LeaderSkinController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time, ICurrencySpendService spend) + public LeaderSkinController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time, ICurrencySpendService spend, IViewerEntitlements entitlements, ICollectionRepository collection) { _db = db; _rewards = rewards; _time = time; _spend = spend; + _entitlements = entitlements; + _collection = collection; } [HttpPost("set")] @@ -64,7 +69,7 @@ public class LeaderSkinController : SVSimController var skin = await _db.LeaderSkins.FindAsync(request.LeaderSkinId); if (skin is null) return BadRequest(new { error = "unknown_skin" }); if (skin.ClassId != request.ClassId) return BadRequest(new { error = "skin_class_mismatch" }); - if (viewer.LeaderSkins.All(s => s.Id != skin.Id)) + if (!_entitlements.OwnsCosmetic(viewer, CosmeticType.Skin, skin.Id)) return BadRequest(new { error = "skin_not_owned" }); classData.LeaderSkin = skin; @@ -83,6 +88,12 @@ public class LeaderSkinController : SVSimController { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); + if (_entitlements.IsFreeplay) + { + var all = (await _collection.GetLeaderSkins()).Select(s => s.Id).OrderBy(id => id).ToList(); + return new LeaderSkinIdsResponse { UserLeaderSkinIds = all }; + } + var ids = await _db.Viewers .Where(v => v.Id == viewerId) .SelectMany(v => v.LeaderSkins.Select(s => s.Id)) @@ -97,10 +108,12 @@ public class LeaderSkinController : SVSimController { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); - var ownedSkinIds = (await _db.Viewers - .Where(v => v.Id == viewerId) - .SelectMany(v => v.LeaderSkins.Select(s => s.Id)) - .ToListAsync()).ToHashSet(); + var ownedSkinIds = _entitlements.IsFreeplay + ? (await _collection.GetLeaderSkins()).Select(s => s.Id).ToHashSet() + : (await _db.Viewers + .Where(v => v.Id == viewerId) + .SelectMany(v => v.LeaderSkins.Select(s => s.Id)) + .ToListAsync()).ToHashSet(); var claimedSeries = (await _db.ViewerLeaderSkinSetClaims .Where(c => c.ViewerId == viewerId) @@ -173,7 +186,7 @@ public class LeaderSkinController : SVSimController var viewer = await LoadViewerGraphAsync(viewerId); // Already-purchased = viewer owns the leader_skin this product grants. - if (viewer.LeaderSkins.Any(s => s.Id == product.LeaderSkinId)) + if (_entitlements.OwnsCosmetic(viewer, CosmeticType.Skin, product.LeaderSkinId)) return BadRequest(new { error = "already_purchased" }); var rewardList = new List(); @@ -207,6 +220,9 @@ public class LeaderSkinController : SVSimController var viewer = await LoadViewerGraphAsync(viewerId); + if (_entitlements.IsFreeplay) + return BadRequest(new { error = "already_purchased" }); + var rewardList = new List(); var debit = await DebitSetPrice(viewer, series, request.SalesType); if (debit.Error is not null) return BadRequest(new { error = debit.Error }); diff --git a/SVSim.EmulatedEntrypoint/Controllers/SleeveController.cs b/SVSim.EmulatedEntrypoint/Controllers/SleeveController.cs index 1f66091..e852639 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/SleeveController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/SleeveController.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore; using SVSim.Database; using SVSim.Database.Enums; using SVSim.Database.Models; +using SVSim.Database.Repositories.Collectibles; using SVSim.Database.Services; using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; @@ -21,12 +22,16 @@ public class SleeveController : SVSimController private readonly SVSimDbContext _db; private readonly RewardGrantService _rewards; private readonly ICurrencySpendService _spend; + private readonly IViewerEntitlements _entitlements; + private readonly ICollectionRepository _collection; - public SleeveController(SVSimDbContext db, RewardGrantService rewards, ICurrencySpendService spend) + public SleeveController(SVSimDbContext db, RewardGrantService rewards, ICurrencySpendService spend, IViewerEntitlements entitlements, ICollectionRepository collection) { _db = db; _rewards = rewards; _spend = spend; + _entitlements = entitlements; + _collection = collection; } [HttpPost("info")] @@ -37,10 +42,12 @@ public class SleeveController : SVSimController // is_purchased_product is "viewer owns at least one sleeve granted by this product". // Loading the viewer's sleeve-id set once and checking each product against it avoids // an N+1 over products. - var ownedSleeveIds = (await _db.Viewers - .Where(v => v.Id == viewerId) - .SelectMany(v => v.Sleeves.Select(s => (long)s.Id)) - .ToListAsync()).ToHashSet(); + var ownedSleeveIds = _entitlements.IsFreeplay + ? (await _collection.GetAllSleeveIds()).Select(id => (long)id).ToHashSet() + : (await _db.Viewers + .Where(v => v.Id == viewerId) + .SelectMany(v => v.Sleeves.Select(s => (long)s.Id)) + .ToListAsync()).ToHashSet(); var series = await _db.SleeveShopSeries .Where(s => s.IsEnabled) @@ -108,6 +115,9 @@ public class SleeveController : SVSimController var viewer = await LoadViewerGraphAsync(viewerId); + if (_entitlements.IsFreeplay) + return BadRequest(new { error = "already_purchased" }); + if (IsProductPurchased(product, viewer.Sleeves.Select(s => (long)s.Id).ToHashSet())) return BadRequest(new { error = "already_purchased" }); diff --git a/SVSim.UnitTests/Controllers/LeaderSkinControllerTests.cs b/SVSim.UnitTests/Controllers/LeaderSkinControllerTests.cs index 4dd7aff..434fce9 100644 --- a/SVSim.UnitTests/Controllers/LeaderSkinControllerTests.cs +++ b/SVSim.UnitTests/Controllers/LeaderSkinControllerTests.cs @@ -123,4 +123,27 @@ public class LeaderSkinControllerTests var resp = await client.PostAsync("/leader_skin/set", JsonBody(json)); Assert.That((int)resp.StatusCode, Is.EqualTo(501)); } + + [Test] + public async Task Set_freeplay_allows_equipping_unowned_skin() + { + using var factory = new SVSimTestFactory(); + await factory.SeedGlobalsAsync(); + long viewerId = await factory.SeedViewerAsync(); + await factory.EnableFreeplayAsync(); + + int classId, skinId; + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var skin = await db.LeaderSkins.FirstAsync(s => s.ClassId != null); + skinId = skin.Id; classId = skin.ClassId!.Value; + } + + using var client = factory.CreateAuthenticatedClient(viewerId); + var json = $$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","class_id":{{classId}},"leader_skin_id":{{skinId}},"is_random_leader_skin":false,"leader_skin_id_list":[]}"""; + var resp = await client.PostAsync("/leader_skin/set", new StringContent(json, System.Text.Encoding.UTF8, "application/json")); + + Assert.That(resp.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.OK), await resp.Content.ReadAsStringAsync()); + } } From 9b2696fac5cd9abbc1b8e281ed13877869378180 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 29 May 2026 14:42:10 -0400 Subject: [PATCH 19/19] test(freeplay): assert DB-untouched invariant on freeplay pack open Crystal-pack open under freeplay with 0 balance: verifies the request succeeds (HTTP 200) and Currency.Crystals is unchanged in the DB afterward. --- .../Controllers/FreeplayInvariantTests.cs | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 SVSim.UnitTests/Controllers/FreeplayInvariantTests.cs diff --git a/SVSim.UnitTests/Controllers/FreeplayInvariantTests.cs b/SVSim.UnitTests/Controllers/FreeplayInvariantTests.cs new file mode 100644 index 0000000..0e97276 --- /dev/null +++ b/SVSim.UnitTests/Controllers/FreeplayInvariantTests.cs @@ -0,0 +1,72 @@ +using System.Net; +using System.Text; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.Database.Models; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Controllers; + +public class FreeplayInvariantTests +{ + private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json"); + + [Test] + public async Task Freeplay_pack_open_leaves_viewer_currency_unchanged() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedCrystalPack(factory, viewerId); + await factory.EnableFreeplayAsync(); + + ulong before; + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + before = (await db.Viewers.FirstAsync(v => v.Id == viewerId)).Currency.Crystals; + } + + // Verify the precondition: viewer has 0 crystals, so without freeplay this would be rejected. + Assert.That(before, Is.EqualTo(0UL), "precondition: viewer must be broke before the open"); + + using var client = factory.CreateAuthenticatedClient(viewerId); + // gacha_type:1 is the parent pack's gacha_type — see project_wire_pack_gacha_type memory. + var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":100002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}"""; + var resp = await client.PostAsync("/pack/open", JsonBody(json)); + Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK), await resp.Content.ReadAsStringAsync()); + + ulong after; + using (var scope2 = factory.Services.CreateScope()) + { + var db2 = scope2.ServiceProvider.GetRequiredService(); + after = (await db2.Viewers.FirstAsync(v => v.Id == viewerId)).Currency.Crystals; + } + + Assert.That(after, Is.EqualTo(before), "freeplay must not write currency to the DB"); + } + + /// + /// Seeds a crystal pack (parent gacha 10001, child gacha_id 100002, TypeDetail=2, cost=100) + /// with the viewer broke (0 crystals). Mirrors the pack shape from + /// PackControllerOpenTests.Open_with_crystals_deducts_crystals — the only difference is + /// Crystals=0 instead of 250, so without freeplay this open would be refused. + /// + private static async Task SeedCrystalPack(SVSimTestFactory f, long viewerId) + { + using var scope = f.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + int baseId = await db.CardSets.Where(s => s.Cards.Count > 0).Select(s => s.Id).FirstAsync(); + db.Packs.Add(new PackConfigEntry + { + Id = 10001, BasePackId = baseId, PackCategory = PackCategory.None, + CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30), + GachaType = 1, GachaDetail = "test", + ChildGachas = { new PackChildGachaEntry { GachaId = 100002, TypeDetail = 2, Cost = 100, CardCount = 8 } }, + }); + var v = await db.Viewers.FirstAsync(x => x.Id == viewerId); + v.Currency.Crystals = 0; + await db.SaveChangesAsync(); + } +}