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 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-29 14:03:35 -04:00
parent d560f9ade4
commit 092176ea1a
2 changed files with 83 additions and 21 deletions

View File

@@ -50,12 +50,13 @@ public class LoadController : SVSimController
private readonly IBattlePassService _battlePass; private readonly IBattlePassService _battlePass;
private readonly IViewerMissionStateService _missionState; private readonly IViewerMissionStateService _missionState;
private readonly SVSimDbContext _db; private readonly SVSimDbContext _db;
private readonly IViewerEntitlements _entitlements;
public LoadController(IViewerRepository viewerRepository, ICardRepository cardRepository, public LoadController(IViewerRepository viewerRepository, ICardRepository cardRepository,
ICollectionRepository collectionRepository, IGlobalsRepository globalsRepository, ICollectionRepository collectionRepository, IGlobalsRepository globalsRepository,
ICardAcquisitionService acquisition, IGameConfigService config, ICardAcquisitionService acquisition, IGameConfigService config,
IBattlePassService battlePass, IViewerMissionStateService missionState, IBattlePassService battlePass, IViewerMissionStateService missionState,
SVSimDbContext db) SVSimDbContext db, IViewerEntitlements entitlements)
{ {
_viewerRepository = viewerRepository; _viewerRepository = viewerRepository;
_cardRepository = cardRepository; _cardRepository = cardRepository;
@@ -66,6 +67,7 @@ public class LoadController : SVSimController
_battlePass = battlePass; _battlePass = battlePass;
_missionState = missionState; _missionState = missionState;
_db = db; _db = db;
_entitlements = entitlements;
} }
[HttpPost("index")] [HttpPost("index")]
@@ -127,20 +129,11 @@ public class LoadController : SVSimController
// * card_set_id=90000 (engine tokens, char_type=4): never collectible // * 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; // 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. // re-confirm the filter if we later move to Option B and start iterating card-sets.
var defaultCards = await _cardRepository.GetDefaultCards(); // Owned-card projection (incl. the freeplay "all cards" path) lives in the entitlements
var defaultCardIds = defaultCards.Select(c => c.Id).ToHashSet(); // service so both modes share one definition.
var ownedCollectibles = viewer.Cards var allCardsAsOwned = await _entitlements.EffectiveOwnedCardsAsync(viewer, ct);
.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();
List<LeaderSkinEntry> allLeaderSkins = await _collectionRepository.GetLeaderSkins(); var cosmetics = await _entitlements.EffectiveCosmeticsAsync(viewer, ct);
var classExpCurve = await _globalsRepository.GetClassExpCurve(); var classExpCurve = await _globalsRepository.GetClassExpCurve();
List<ClassExp> classExps = new(); List<ClassExp> classExps = new();
@@ -179,7 +172,13 @@ public class LoadController : SVSimController
{ {
UserTutorial = new UserTutorial { TutorialStep = viewer.MissionData.TutorialState }, UserTutorial = new UserTutorial { TutorialStep = viewer.MissionData.TutorialState },
UserInfo = new UserInfo(deviceType, viewer), 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(), UserItems = viewer.Items.Select(item => new UserItem(item)).ToList(),
SpotPoint = checked((int)viewer.Currency.SpotPoints), SpotPoint = checked((int)viewer.Currency.SpotPoints),
UserRotationDecks = new UserFormatDeckInfo UserRotationDecks = new UserFormatDeckInfo
@@ -199,13 +198,13 @@ public class LoadController : SVSimController
}, },
UserCards = allCardsAsOwned.Select(card => new UserCard(card)).ToList(), UserCards = allCardsAsOwned.Select(card => new UserCard(card)).ToList(),
UserClasses = viewer.Classes.Select(vc => new UserClass(vc)).ToList(), UserClasses = viewer.Classes.Select(vc => new UserClass(vc)).ToList(),
Sleeves = viewer.Sleeves.Select(s => new SleeveIdentifier { SleeveId = s.Id }).ToList(), Sleeves = cosmetics.SleeveIds.Select(id => new SleeveIdentifier { SleeveId = id }).ToList(),
UserEmblems = viewer.Emblems.Select(e => new EmblemIdentifier { EmblemId = e.Id }).ToList(), UserEmblems = cosmetics.EmblemIds.Select(id => new EmblemIdentifier { EmblemId = id }).ToList(),
UserDegrees = viewer.Degrees.Select(d => new DegreeIdentifier { DegreeId = d.Id }).ToList(), UserDegrees = cosmetics.DegreeIds.Select(id => new DegreeIdentifier { DegreeId = id }).ToList(),
LeaderSkins = allLeaderSkins LeaderSkins = cosmetics.AllLeaderSkins
.Select(skin => new UserLeaderSkin(skin, viewer.LeaderSkins.Any(vs => vs.Id == skin.Id))) .Select(skin => new UserLeaderSkin(skin, cosmetics.OwnedLeaderSkinIds.Contains(skin.Id)))
.ToList(), .ToList(),
MyPageBackgrounds = viewer.MyPageBackgrounds.Select(mpbg => mpbg.Id.ToString()).ToList(), MyPageBackgrounds = cosmetics.MyPageBackgroundIds.Select(id => id.ToString()).ToList(),
LootBoxRegulations = new LootBoxRegulations(), LootBoxRegulations = new LootBoxRegulations(),
GatheringInfo = new GatheringInfo(), GatheringInfo = new GatheringInfo(),
IsBattlePassPeriod = rotation.IsBattlePassPeriod, IsBattlePassPeriod = rotation.IsBattlePassPeriod,

View File

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