From 302bf17c310092b9cfed4df455e29b7518c1e477 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 29 May 2026 14:36:50 -0400 Subject: [PATCH] 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()); + } }