refactor(inventory): delete old primitives after InventoryService cutover
Removed RewardGrantService, CurrencySpendService, ICurrencySpendService, ViewerEntitlements, IViewerEntitlements, CardAcquisitionService, ICardAcquisitionService, CardGrantResult and their tests (RewardGrantServiceTests, CurrencySpendServiceTests, CardAcquisitionServiceTests, ViewerEntitlementsTests). Removed four DI registrations from Program.cs. No caller references any deleted type; GrantedReward and EffectiveCosmetics were pre-moved to InventoryGrantTypes.cs in the prior commit. Build clean, 712/712 tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,51 +0,0 @@
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Services;
|
||||
|
||||
public class CurrencySpendService : ICurrencySpendService
|
||||
{
|
||||
private readonly IViewerEntitlements _entitlements;
|
||||
|
||||
public CurrencySpendService(IViewerEntitlements entitlements) => _entitlements = entitlements;
|
||||
|
||||
public Task<SpendResult> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Centralized debit primitive — the symmetric twin of <c>RewardGrantService.ApplyAsync</c>.
|
||||
/// Encapsulates the affordability-check + deduction + post-state-total pattern that was inlined
|
||||
/// across the shop/pack controllers. Does NOT call <c>SaveChangesAsync</c>; the caller saves.
|
||||
/// Freeplay (for Crystal/Rupee/RedEther) makes spends always succeed without deducting.
|
||||
/// </summary>
|
||||
public interface ICurrencySpendService
|
||||
{
|
||||
Task<SpendResult> TrySpendAsync(Viewer viewer, SpendCurrency currency, long cost, CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Include precondition:</b> methods that inspect the viewer's collections require the
|
||||
/// viewer to have been loaded with <c>.Include(v => v.Cards).ThenInclude(c => c.Card)</c>
|
||||
/// and the cosmetic collections
|
||||
/// (<c>Sleeves</c>, <c>Emblems</c>, <c>Degrees</c>, <c>LeaderSkins</c>, <c>MyPageBackgrounds</c>)
|
||||
/// 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).
|
||||
/// </remarks>
|
||||
public interface IViewerEntitlements
|
||||
{
|
||||
/// <summary>True when the global Freeplay config section is enabled.</summary>
|
||||
bool IsFreeplay { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>viewer.Currency</c> field.
|
||||
/// </summary>
|
||||
long EffectiveBalance(Viewer viewer, SpendCurrency currency);
|
||||
|
||||
bool OwnsCard(Viewer viewer, long cardId);
|
||||
|
||||
/// <summary><paramref name="type"/> uses <see cref="CosmeticType"/> (Skin == leader skin).</summary>
|
||||
bool OwnsCosmetic(Viewer viewer, CosmeticType type, int id);
|
||||
|
||||
/// <summary>The full owned-card projection for /load/index's user_card_list.</summary>
|
||||
Task<IReadOnlyList<OwnedCardEntry>> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The cosmetic id-lists + leader-skin catalog/owned-set for /load/index.</summary>
|
||||
Task<EffectiveCosmetics> EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Single canonical grant primitive for every <see cref="UserGoodsType"/> the server hands to a
|
||||
/// viewer. Switch on the type, mutate the appropriate viewer collection / <see cref="ViewerCurrency"/>
|
||||
/// field, return the wire-shape entries to embed in the response's <c>reward_list</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>DO NOT reimplement reward dispatch in a controller or new helper.</b> This service handles
|
||||
/// RedEther, Crystal, SpotCardPoint, Item, Card (with <see cref="CardCosmeticReward"/> cascade),
|
||||
/// Sleeve, Emblem, Degree, Rupy, Skin, MyPageBG — everything except the dead-letter SpotCard /
|
||||
/// SpotCardOnlyLatestCardPack slots (use Card=5 instead). Endpoint code that takes a
|
||||
/// list of <c>(type, id, num)</c> tuples should iterate and call <see cref="ApplyAsync"/>
|
||||
/// per tuple — never switch on type yourself, never filter to "only card-typed rewards", never
|
||||
/// build a second dispatch table. Past duplicate implementations (ICardAcquisitionService in the
|
||||
/// EmulatedEntrypoint project, the first pass at /build_deck/buy) all silently dropped subsets of
|
||||
/// types and produced the same bug: wire reward visible but viewer's collection unchanged. When a
|
||||
/// new reward type comes up, add a case here. See <c>feedback_reward_grant_service</c> memory.
|
||||
/// </para>
|
||||
///
|
||||
/// Card grants additionally run the <see cref="CardCosmeticReward"/> cascade: any cosmetic
|
||||
/// associated with the granted card that the viewer doesn't yet own is granted too, and produces
|
||||
/// an additional entry in the returned list. That's why the return type is a list: most types
|
||||
/// produce one entry, Card produces 1 + N.
|
||||
///
|
||||
/// Caller is responsible for <see cref="SVSimDbContext.SaveChangesAsync(System.Threading.CancellationToken)"/> —
|
||||
/// this service only mutates the in-memory graph so a controller can stack several grants in
|
||||
/// a single transaction.
|
||||
/// </summary>
|
||||
public sealed class RewardGrantService
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly ILogger<RewardGrantService> _log;
|
||||
|
||||
public RewardGrantService(SVSimDbContext db, ILogger<RewardGrantService> log)
|
||||
{
|
||||
_db = db;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<GrantedReward>> ApplyAsync(
|
||||
Viewer viewer, UserGoodsType type, long detailId, int num, CancellationToken ct = default)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case UserGoodsType.Sleeve:
|
||||
AddCosmeticIfMissing(viewer.Sleeves, detailId, _db.Sleeves);
|
||||
return Single(type, detailId, 1);
|
||||
|
||||
case UserGoodsType.Emblem:
|
||||
AddCosmeticIfMissing(viewer.Emblems, detailId, _db.Emblems);
|
||||
return Single(type, detailId, 1);
|
||||
|
||||
case UserGoodsType.Skin: // LeaderSkin in our schema
|
||||
AddCosmeticIfMissing(viewer.LeaderSkins, detailId, _db.LeaderSkins);
|
||||
return Single(type, detailId, 1);
|
||||
|
||||
case UserGoodsType.Degree:
|
||||
AddCosmeticIfMissing(viewer.Degrees, detailId, _db.Degrees);
|
||||
return Single(type, detailId, 1);
|
||||
|
||||
case UserGoodsType.MyPageBG:
|
||||
AddCosmeticIfMissing(viewer.MyPageBackgrounds, detailId, _db.MyPageBackgrounds);
|
||||
return Single(type, detailId, 1);
|
||||
|
||||
case UserGoodsType.Rupy:
|
||||
viewer.Currency.Rupees += (ulong)num;
|
||||
return Single(type, detailId, checked((int)viewer.Currency.Rupees));
|
||||
|
||||
case UserGoodsType.Crystal:
|
||||
viewer.Currency.Crystals += (ulong)num;
|
||||
return Single(type, detailId, checked((int)viewer.Currency.Crystals));
|
||||
|
||||
case UserGoodsType.RedEther:
|
||||
viewer.Currency.RedEther += (ulong)num;
|
||||
return Single(type, detailId, checked((int)viewer.Currency.RedEther));
|
||||
|
||||
case UserGoodsType.SpotCardPoint:
|
||||
viewer.Currency.SpotPoints += (ulong)num;
|
||||
return Single(type, detailId, checked((int)viewer.Currency.SpotPoints));
|
||||
|
||||
case UserGoodsType.Item:
|
||||
{
|
||||
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId);
|
||||
if (owned is null)
|
||||
{
|
||||
var item = _db.Items.Find((int)detailId)
|
||||
?? throw new InvalidOperationException($"Item {detailId} not in catalog");
|
||||
viewer.Items.Add(new OwnedItemEntry { Item = item, Count = num, Viewer = viewer });
|
||||
return Single(type, detailId, num);
|
||||
}
|
||||
owned.Count += num;
|
||||
return Single(type, detailId, owned.Count);
|
||||
}
|
||||
|
||||
case UserGoodsType.Card:
|
||||
return await ApplyCardAsync(viewer, detailId, num, ct);
|
||||
|
||||
case UserGoodsType.SpotCard:
|
||||
case UserGoodsType.SpotCardOnlyLatestCardPack:
|
||||
// Spot-card-typed grants don't appear in captures — emitters always use Card=5
|
||||
// with the spot-card-specific id. These two enum slots remain unimplemented; if a
|
||||
// capture ever shows one in a reward_list we'll know to wire them up here.
|
||||
throw new NotSupportedException(
|
||||
$"{type} rewards are not yet supported — emitters use Card=5 instead.");
|
||||
|
||||
default:
|
||||
throw new NotSupportedException($"UserGoodsType {type} not yet handled by RewardGrantService");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<GrantedReward>> ApplyCardAsync(
|
||||
Viewer viewer, long cardId, int num, CancellationToken ct)
|
||||
{
|
||||
// Find-or-add OwnedCardEntry. Mirrors the primitive that used to live in
|
||||
// IPackRepository.GrantCardsToViewer — now inline so we own the in-memory-only contract.
|
||||
var owned = viewer.Cards.FirstOrDefault(c => c.Card.Id == cardId);
|
||||
int postCount;
|
||||
if (owned is null)
|
||||
{
|
||||
var card = await _db.Cards.FirstOrDefaultAsync(c => c.Id == cardId, ct)
|
||||
?? throw new InvalidOperationException($"Card {cardId} not in catalog");
|
||||
owned = new OwnedCardEntry { Card = card, Count = num, IsProtected = false };
|
||||
viewer.Cards.Add(owned);
|
||||
postCount = num;
|
||||
}
|
||||
else
|
||||
{
|
||||
owned.Count += num;
|
||||
postCount = owned.Count;
|
||||
}
|
||||
|
||||
var results = new List<GrantedReward>
|
||||
{
|
||||
new((int)UserGoodsType.Card, cardId, postCount),
|
||||
};
|
||||
|
||||
// Cascade: cosmetic mappings live on the non-foil row. If the granted card is foil
|
||||
// (card_id ends in 1, IsFoil=true), look up cascade against cardId - 1.
|
||||
long lookupId = owned.Card.IsFoil ? cardId - 1 : cardId;
|
||||
|
||||
var cascade = await _db.CardCosmeticRewards
|
||||
.Where(r => r.CardId == lookupId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var reward in cascade)
|
||||
{
|
||||
if (TryAddCascadeCosmetic(viewer, reward, lookupId))
|
||||
{
|
||||
// CosmeticType numeric values are identical to UserGoodsType — direct cast is safe.
|
||||
results.Add(new GrantedReward((int)reward.Type, reward.CosmeticId, 1));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<GrantedReward> Single(UserGoodsType type, long id, int num)
|
||||
=> new[] { new GrantedReward((int)type, id, num) };
|
||||
|
||||
private bool TryAddCascadeCosmetic(Viewer viewer, CardCosmeticReward reward, long forCardId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return reward.Type switch
|
||||
{
|
||||
CosmeticType.Sleeve => AddCosmeticIfMissing(viewer.Sleeves, reward.CosmeticId, _db.Sleeves),
|
||||
CosmeticType.Emblem => AddCosmeticIfMissing(viewer.Emblems, reward.CosmeticId, _db.Emblems),
|
||||
CosmeticType.Skin => AddCosmeticIfMissing(viewer.LeaderSkins, reward.CosmeticId, _db.LeaderSkins),
|
||||
CosmeticType.Degree => AddCosmeticIfMissing(viewer.Degrees, reward.CosmeticId, _db.Degrees),
|
||||
CosmeticType.MyPageBG => AddCosmeticIfMissing(viewer.MyPageBackgrounds, reward.CosmeticId, _db.MyPageBackgrounds),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_log.LogWarning(ex,
|
||||
"Card cascade: cosmetic {Type} {Id} for card {CardId} skipped (master row missing)",
|
||||
reward.Type, reward.CosmeticId, forCardId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool AddCosmeticIfMissing<T>(List<T> collection, long detailId, DbSet<T> catalog) where T : class
|
||||
{
|
||||
bool alreadyOwned = collection.Any(e => GetId(e) == detailId);
|
||||
if (alreadyOwned) return false;
|
||||
|
||||
var entity = catalog.Find(checked((int)detailId))
|
||||
?? throw new InvalidOperationException(
|
||||
$"Cosmetic id {detailId} not in catalog for type {typeof(T).Name}");
|
||||
collection.Add(entity);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reflectively reads an entity's Id property — works for both <c>BaseEntity<int></c>
|
||||
/// (cosmetics) and <c>BaseEntity<long></c> (e.g. Viewer/Card) without forcing two
|
||||
/// non-generic overloads of <see cref="AddCosmeticIfMissing"/>.
|
||||
/// </summary>
|
||||
private static long GetId<T>(T e)
|
||||
{
|
||||
var prop = typeof(T).GetProperty("Id")
|
||||
?? throw new InvalidOperationException($"Type {typeof(T).Name} missing Id property");
|
||||
var val = prop.GetValue(e);
|
||||
return val switch { long l => l, int i => i, _ => 0 };
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user