feat(services): ViewerEntitlements (freeplay-aware ownership/balance authority)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
107
SVSim.Database/Services/ViewerEntitlements.cs
Normal file
107
SVSim.Database/Services/ViewerEntitlements.cs
Normal file
@@ -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<FreeplayConfig>();
|
||||
|
||||
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<IReadOnlyList<OwnedCardEntry>> 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<EffectiveCosmetics> 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());
|
||||
}
|
||||
}
|
||||
99
SVSim.UnitTests/Services/ViewerEntitlementsTests.cs
Normal file
99
SVSim.UnitTests/Services/ViewerEntitlementsTests.cs
Normal file
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<FreeplayConfig>(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<SVSimDbContext>();
|
||||
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<SVSimDbContext>();
|
||||
// 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<SVSimDbContext>();
|
||||
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<SVSimDbContext>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user