Consolidation

This commit is contained in:
gamer147
2026-05-25 16:34:24 -04:00
parent 9b051c444c
commit 8e913578ff
14 changed files with 566 additions and 280 deletions

View File

@@ -59,6 +59,9 @@ public class CardInventoryRepository : ICardInventoryRepository
totalVials += (ulong)owned.Card.CollectionInfo!.DustReward * (ulong)num;
postCounts[cardId] = owned.Count;
}
// Direct credit (not via RewardGrantService.ApplyAsync) because destruct is a debit-pair
// operation (destroy cards + credit vials) handled atomically here. ApplyAsync is the
// standard path for one-shot reward grants — see RewardGrantService for that pattern.
viewer.Currency.RedEther += totalVials;
// Deck auto-strip: any deck holding more copies of a destructed card than the viewer now owns

View File

@@ -9,5 +9,4 @@ public interface IPackRepository
Task<Dictionary<int, ViewerPackOpenCount>> GetOpenCountsForViewer(long viewerId);
Task IncrementOpenCount(long viewerId, int parentGachaId, int by);
Task MarkDailyFreeUsed(long viewerId, int parentGachaId, DateTime when);
Task GrantCardsToViewer(long viewerId, IEnumerable<long> cardIds);
}

View File

@@ -63,28 +63,4 @@ public class PackRepository : IPackRepository
await _db.SaveChangesAsync();
}
public async Task GrantCardsToViewer(long viewerId, IEnumerable<long> cardIds)
{
var viewer = await _db.Viewers
.Include(v => v.Cards).ThenInclude(c => c.Card)
.FirstAsync(v => v.Id == viewerId);
var byId = viewer.Cards.ToDictionary(c => c.Card.Id);
foreach (var grp in cardIds.GroupBy(id => id))
{
if (byId.TryGetValue(grp.Key, out var existing))
{
existing.Count += grp.Count();
}
else
{
// Look up the card by id and attach it so we don't insert a phantom Card row.
var card = await _db.Cards.FirstAsync(c => c.Id == grp.Key);
var owned = new OwnedCardEntry { Card = card, Count = grp.Count(), IsProtected = false };
viewer.Cards.Add(owned);
byId[grp.Key] = owned;
}
}
await _db.SaveChangesAsync();
}
}

View File

@@ -1,66 +1,79 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SVSim.Database.Enums;
using SVSim.Database.Models;
namespace SVSim.Database.Services;
/// <summary>
/// Wire-shape returned by <see cref="RewardGrantService.Apply"/>. Field names match the
/// <c>reward_list</c> entries used by <c>/pack/open</c> and <c>/basic_puzzle/finish</c>.
/// reward_num is a POST-STATE TOTAL for currencies and a count for collection grants — see
/// <see cref="Models.RewardListEntry"/>... see SVSim.EmulatedEntrypoint.Models.Dtos.RewardListEntry
/// for the on-the-wire DTO and the rationale.
/// Wire-shape entry returned by <see cref="RewardGrantService.ApplyAsync"/>. Field names match
/// the <c>reward_list</c> entries used by <c>/pack/open</c>, <c>/basic_puzzle/finish</c>, and
/// <c>/story/*/finish</c>. reward_num is a POST-STATE TOTAL for currencies and a count for
/// collection grants — see <see cref="Models.RewardListEntry"/>.
/// </summary>
public sealed record GrantedReward(int RewardType, long RewardId, int RewardNum);
/// <summary>
/// General reward-grant primitive. Switches on <see cref="UserGoodsType"/>, mutates the
/// appropriate viewer collection or <see cref="ViewerCurrency"/> field, and returns the
/// wire-shape entry the caller should embed in its response's reward_list.
/// Single canonical grant primitive. Switch on <see cref="UserGoodsType"/>, mutate the
/// appropriate viewer collection / <see cref="ViewerCurrency"/> field, and return the
/// wire-shape entries the caller should embed in its response's reward_list.
///
/// Caller is responsible for <c>SaveChangesAsync</c> — this service only mutates the in-memory
/// graph so a controller can stack several grants in a single transaction.
/// 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;
public RewardGrantService(SVSimDbContext db) => _db = db;
private readonly ILogger<RewardGrantService> _log;
public GrantedReward Apply(Viewer viewer, UserGoodsType type, long detailId, int num)
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 new GrantedReward((int)type, detailId, 1);
return Single(type, detailId, 1);
case UserGoodsType.Emblem:
AddCosmeticIfMissing(viewer.Emblems, detailId, _db.Emblems);
return new GrantedReward((int)type, detailId, 1);
return Single(type, detailId, 1);
case UserGoodsType.Skin: // LeaderSkin in our schema
AddCosmeticIfMissing(viewer.LeaderSkins, detailId, _db.LeaderSkins);
return new GrantedReward((int)type, detailId, 1);
return Single(type, detailId, 1);
case UserGoodsType.Degree:
AddCosmeticIfMissing(viewer.Degrees, detailId, _db.Degrees);
return new GrantedReward((int)type, detailId, 1);
return Single(type, detailId, 1);
case UserGoodsType.MyPageBG:
AddCosmeticIfMissing(viewer.MyPageBackgrounds, detailId, _db.MyPageBackgrounds);
return new GrantedReward((int)type, detailId, 1);
return Single(type, detailId, 1);
case UserGoodsType.Rupy:
viewer.Currency.Rupees += (ulong)num;
return new GrantedReward((int)type, detailId, checked((int)viewer.Currency.Rupees));
return Single(type, detailId, checked((int)viewer.Currency.Rupees));
case UserGoodsType.Crystal:
viewer.Currency.Crystals += (ulong)num;
return new GrantedReward((int)type, detailId, checked((int)viewer.Currency.Crystals));
return Single(type, detailId, checked((int)viewer.Currency.Crystals));
case UserGoodsType.RedEther:
viewer.Currency.RedEther += (ulong)num;
return new GrantedReward((int)type, detailId, checked((int)viewer.Currency.RedEther));
return Single(type, detailId, checked((int)viewer.Currency.RedEther));
case UserGoodsType.Item:
{
@@ -70,37 +83,110 @@ public sealed class RewardGrantService
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 new GrantedReward((int)type, detailId, num);
return Single(type, detailId, num);
}
owned.Count += num;
return new GrantedReward((int)type, detailId, owned.Count);
return Single(type, detailId, owned.Count);
}
case UserGoodsType.Card:
return await ApplyCardAsync(viewer, detailId, num, ct);
case UserGoodsType.SpotCard:
case UserGoodsType.SpotCardOnlyLatestCardPack:
// TODO: spot cards are currently global in our seed data; the existence of these
// reward types suggests there's a mix of global + per-player spot cards. Revisit
// when per-player spot-card infrastructure lands.
throw new NotSupportedException(
$"{type} rewards are out of Phase 1 scope — extend RewardGrantService when /pack/open or similar needs them.");
$"{type} rewards are not yet supported — see SpotCard TODO in RewardGrantService.");
default:
throw new NotSupportedException($"UserGoodsType {type} not yet handled by RewardGrantService");
}
}
private static void AddCosmeticIfMissing<T>(List<T> collection, long detailId, DbSet<T> catalog) where T : class
private async Task<IReadOnlyList<GrantedReward>> ApplyCardAsync(
Viewer viewer, long cardId, int num, CancellationToken ct)
{
// Cosmetic ownership is binary — if the viewer already owns it, the grant is a no-op
// (matches client UpdateHaveUserGoodsNum behaviour which just calls .Acquired() each time).
bool alreadyOwned = collection.Any(e => GetId(e) == detailId);
if (alreadyOwned) return;
// 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;
// Wire reward_detail_id is long, but every cosmetic catalog in this codebase uses
// BaseEntity<int>; downcast for Find. The checked() throws OverflowException if a
// future capture ships a real long id rather than silently truncating it.
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>

View File

@@ -79,7 +79,7 @@ public class LoadController : SVSimController
// .AsNoTracking() — the local `viewer` instance is detached, and the service's writes
// (on a separate tracked instance) won't appear on this snapshot. Without the re-fetch,
// the response payload would be one /load/index behind on newly-granted cosmetics.
await _acquisition.GrantAsync(viewer.Id, newCardIds: null);
await _acquisition.BackfillCosmeticsAsync(viewer.Id);
viewer = await _viewerRepository.GetViewerByShortUdid(shortUdid);
if (viewer is null)
{

View File

@@ -196,7 +196,7 @@ public class PackController : SVSimController
// Draw + persist. DAILY single overrides packNumber to 1 (it's a one-card open).
int drawCount = child.IsDailySingle ? 1 : packNumber;
var draw = _opener.Draw(pack, _pools, drawCount, request.ExcludeCardIds, _rng);
var grant = await _acquisition.GrantAsync(viewerId, draw.Cards.Select(c => c.CardId));
var grant = await _acquisition.GrantManyAsync(viewerId, draw.Cards.Select(c => c.CardId));
// Build reward_list. The service produces the type=5 (Card) entries with post-state counts
// plus any cosmetic grants. Currency entry (type=2 Crystals or type=9 Rupy) stays in the

View File

@@ -2,6 +2,7 @@ using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using SVSim.Database;
using SVSim.Database.Repositories.Globals;
using SVSim.Database.Repositories.Viewer;
@@ -26,17 +27,20 @@ public class PuzzleController : SVSimController
private readonly IPuzzleClearRepository _clears;
private readonly PuzzleMissionEvaluator _evaluator;
private readonly RewardGrantService _rewards;
private readonly ILogger<PuzzleController> _logger;
public PuzzleController(
IPuzzleCatalogRepository catalog,
IPuzzleClearRepository clears,
PuzzleMissionEvaluator evaluator,
RewardGrantService rewards)
RewardGrantService rewards,
ILogger<PuzzleController> logger)
{
_catalog = catalog;
_clears = clears;
_evaluator = evaluator;
_rewards = rewards;
_logger = logger;
}
/// <summary>/basic_puzzle/info — full catalog of groups + per-viewer clear flags.</summary>
@@ -175,6 +179,7 @@ public class PuzzleController : SVSimController
// to avoid the cartesian-explode pitfall (CLAUDE.md "EF split query").
var ctx = HttpContext.RequestServices.GetRequiredService<SVSimDbContext>();
var viewer = await ctx.Viewers
.Include(v => v.Cards).ThenInclude(c => c.Card)
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.LeaderSkins)
@@ -186,11 +191,22 @@ public class PuzzleController : SVSimController
foreach (var status in fresh)
{
var granted = _rewards.Apply(
viewer,
(SVSim.Database.Enums.UserGoodsType)status.Mission.RewardType,
status.Mission.RewardDetailId,
status.Mission.RewardNumber);
IReadOnlyList<GrantedReward> granted;
try
{
granted = await _rewards.ApplyAsync(
viewer,
(SVSim.Database.Enums.UserGoodsType)status.Mission.RewardType,
status.Mission.RewardDetailId,
status.Mission.RewardNumber);
}
catch (NotSupportedException ex)
{
_logger.LogWarning(ex,
"PuzzleController: skipping unsupported reward_type={Type} detail={Detail} num={Num} for mission={MissionId}",
status.Mission.RewardType, status.Mission.RewardDetailId, status.Mission.RewardNumber, status.Mission.Id);
continue;
}
response.AchievedInfo.AchievedMissionList.Add(new PuzzleAchievedMissionEntry
{
@@ -202,12 +218,15 @@ public class PuzzleController : SVSimController
MissionRewardDetailId = status.Mission.RewardDetailId,
MissionRewardNumber = status.Mission.RewardNumber,
});
response.RewardList.Add(new TreasureRewardResponse
foreach (var g in granted)
{
RewardType = granted.RewardType,
RewardId = granted.RewardId,
RewardNum = granted.RewardNum,
});
response.RewardList.Add(new TreasureRewardResponse
{
RewardType = g.RewardType,
RewardId = g.RewardId,
RewardNum = g.RewardNum,
});
}
}
await ctx.SaveChangesAsync();

View File

@@ -1,9 +1,8 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Pack;
using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Models.Dtos;
namespace SVSim.EmulatedEntrypoint.Services;
@@ -11,78 +10,29 @@ namespace SVSim.EmulatedEntrypoint.Services;
public class CardAcquisitionService : ICardAcquisitionService
{
private readonly SVSimDbContext _db;
private readonly IPackRepository _packs;
private readonly ILogger<CardAcquisitionService> _log;
private readonly RewardGrantService _rewards;
public CardAcquisitionService(SVSimDbContext db, IPackRepository packs, ILogger<CardAcquisitionService> log)
public CardAcquisitionService(SVSimDbContext db, RewardGrantService rewards)
{
_db = db; _packs = packs; _log = log;
_db = db;
_rewards = rewards;
}
public async Task<CardGrantResult> GrantAsync(long viewerId, IEnumerable<long>? newCardIds = null)
public async Task<CardGrantResult> GrantManyAsync(long viewerId, IEnumerable<long> newCardIds)
{
var viewer = await LoadViewerWithGraph(viewerId);
var rewardList = new List<RewardListEntry>();
var viewer = await _db.Viewers
.Include(v => v.Cards).ThenInclude(c => c.Card)
.Include(v => v.LeaderSkins)
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.Degrees)
.Include(v => v.MyPageBackgrounds)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
List<long> lookupSourceCardIds;
if (newCardIds is not null)
// Bucket the input by id so multi-copy grants increment count once but cascade fires once.
foreach (var grp in newCardIds.GroupBy(id => id))
{
var newIds = newCardIds.ToList();
await _packs.GrantCardsToViewer(viewerId, newIds);
// GrantCardsToViewer mutates OwnedCardEntry rows on the same scoped SVSimDbContext
// AND commits them via its own SaveChangesAsync. The change tracker already exposes
// the post-state via viewer.Cards. Note this means GrantAsync performs two saves total
// (one inside the repo call, one at the end for cosmetic grants) — accepted because
// any inconsistency in the failure window is self-healing via the next /load/index
// backfill.
lookupSourceCardIds = newIds.Distinct().ToList();
foreach (var distinctId in lookupSourceCardIds)
{
var owned = viewer.Cards.First(c => c.Card.Id == distinctId);
rewardList.Add(new RewardListEntry { RewardType = 5, RewardId = distinctId, RewardNum = owned.Count });
}
}
else
{
// Backfill mode: scan all owned cards for missing cosmetics. No card-count mutation.
lookupSourceCardIds = viewer.Cards.Select(c => c.Card.Id).Distinct().ToList();
}
// Foil resolution: cosmetic mappings are recorded on the non-foil card row.
// Foil twins (card_id + 1) inherit via the universal +1 convention.
var lookupCardIds = lookupSourceCardIds
.Select(id =>
{
var card = viewer.Cards.FirstOrDefault(c => c.Card.Id == id)?.Card;
return (card?.IsFoil == true) ? id - 1 : id;
})
.Distinct()
.ToList();
var rewards = await _db.CardCosmeticRewards
.Where(r => lookupCardIds.Contains(r.CardId))
.ToListAsync();
foreach (var reward in rewards)
{
if (await TryGrant(viewer, reward))
int count = grp.Count();
var granted = await _rewards.ApplyAsync(viewer, UserGoodsType.Card, grp.Key, count);
foreach (var g in granted)
{
rewardList.Add(new RewardListEntry
{
RewardType = (int)reward.Type,
RewardId = reward.CosmeticId,
RewardNum = reward.Quantity,
RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum,
});
}
}
@@ -91,59 +41,61 @@ public class CardAcquisitionService : ICardAcquisitionService
return new CardGrantResult(rewardList);
}
/// <summary>
/// Returns true if the cosmetic was newly granted (caller should emit a reward_list entry).
/// Returns false if the viewer already owned it or the master row is missing (defensive log).
/// </summary>
private async Task<bool> TryGrant(Viewer viewer, CardCosmeticReward reward)
public async Task<CardGrantResult> BackfillCosmeticsAsync(long viewerId)
{
var id = (int)reward.CosmeticId; // master tables use int Id
var viewer = await LoadViewerWithGraph(viewerId);
var rewardList = new List<RewardListEntry>();
switch (reward.Type)
// Foil resolution: cascade rows live on non-foil ids. Apply the +1 convention.
var lookupCardIds = viewer.Cards
.Select(c => c.Card.IsFoil ? c.Card.Id - 1 : c.Card.Id)
.Distinct()
.ToList();
var cascade = await _db.CardCosmeticRewards
.Where(r => lookupCardIds.Contains(r.CardId))
.ToListAsync();
foreach (var reward in cascade)
{
case CosmeticType.Skin:
// Skip if the viewer already owns this cosmetic. ApplyAsync's cosmetic branches
// unconditionally return a wire entry (top-level grant semantics), so we must
// filter at the caller side to avoid emitting "+0 received" lines for cosmetics
// the viewer has owned for ages.
if (AlreadyOwnsCosmetic(viewer, reward.Type, reward.CosmeticId)) continue;
var goodsType = (UserGoodsType)(int)reward.Type;
var granted = await _rewards.ApplyAsync(viewer, goodsType, reward.CosmeticId, 1);
foreach (var g in granted)
{
if (viewer.LeaderSkins.Any(s => s.Id == id)) return false;
var master = await _db.LeaderSkins.FindAsync(id);
if (master is null) { _log.LogWarning("Skin master row missing for cosmetic_id={Id}", id); return false; }
viewer.LeaderSkins.Add(master);
return true;
rewardList.Add(new RewardListEntry
{
RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum,
});
}
case CosmeticType.Sleeve:
{
if (viewer.Sleeves.Any(s => s.Id == id)) return false;
var master = await _db.Sleeves.FindAsync(id);
if (master is null) { _log.LogWarning("Sleeve master row missing for cosmetic_id={Id}", id); return false; }
viewer.Sleeves.Add(master);
return true;
}
case CosmeticType.Emblem:
{
if (viewer.Emblems.Any(e => e.Id == id)) return false;
var master = await _db.Emblems.FindAsync(id);
if (master is null) { _log.LogWarning("Emblem master row missing for cosmetic_id={Id}", id); return false; }
viewer.Emblems.Add(master);
return true;
}
case CosmeticType.Degree:
{
if (viewer.Degrees.Any(d => d.Id == id)) return false;
var master = await _db.Degrees.FindAsync(id);
if (master is null) { _log.LogWarning("Degree master row missing for cosmetic_id={Id}", id); return false; }
viewer.Degrees.Add(master);
return true;
}
case CosmeticType.MyPageBG:
{
if (viewer.MyPageBackgrounds.Any(b => b.Id == id)) return false;
var master = await _db.MyPageBackgrounds.FindAsync(id);
if (master is null) { _log.LogWarning("MyPageBG master row missing for cosmetic_id={Id}", id); return false; }
viewer.MyPageBackgrounds.Add(master);
return true;
}
default:
_log.LogWarning("Unknown CosmeticType {Type} for card {CardId}", reward.Type, reward.CardId);
return false;
}
await _db.SaveChangesAsync();
return new CardGrantResult(rewardList);
}
private static bool AlreadyOwnsCosmetic(Viewer viewer, CosmeticType type, long id) => type switch
{
CosmeticType.Sleeve => viewer.Sleeves.Any(s => s.Id == id),
CosmeticType.Emblem => viewer.Emblems.Any(e => e.Id == id),
CosmeticType.Skin => viewer.LeaderSkins.Any(s => s.Id == id),
CosmeticType.Degree => viewer.Degrees.Any(d => d.Id == id),
CosmeticType.MyPageBG => viewer.MyPageBackgrounds.Any(b => b.Id == id),
_ => false,
};
private Task<Viewer> LoadViewerWithGraph(long viewerId) => _db.Viewers
.Include(v => v.Cards).ThenInclude(c => c.Card)
.Include(v => v.LeaderSkins)
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.Degrees)
.Include(v => v.MyPageBackgrounds)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
}

View File

@@ -3,16 +3,16 @@ namespace SVSim.EmulatedEntrypoint.Services;
public interface ICardAcquisitionService
{
/// <summary>
/// Grant cards + associated cosmetics in one transaction.
///
/// • <paramref name="newCardIds"/> non-null → increments OwnedCardEntry for each via
/// the existing IPackRepository.GrantCardsToViewer primitive, then grants any
/// cosmetics associated with those cards that the viewer doesn't yet own.
/// • <paramref name="newCardIds"/> null → backfill mode: skips card mutation,
/// scans viewer.Cards, grants missing cosmetics.
///
/// Returns wire-shape RewardList in both modes. Backfill callers typically discard.
/// All ownership writes happen in a single SaveChangesAsync.
/// Grant N cards + their CardCosmeticReward cascades in a single transaction.
/// Used by /pack/open and any future endpoint that grants new cards in bulk.
/// Returns wire-shape reward_list entries (post-state counts for cards, single-grant
/// entries for any newly-added cosmetics).
/// </summary>
Task<CardGrantResult> GrantAsync(long viewerId, IEnumerable<long>? newCardIds = null);
Task<CardGrantResult> GrantManyAsync(long viewerId, IEnumerable<long> newCardIds);
/// <summary>
/// Scan all owned cards for missing CardCosmeticReward cosmetics; grant any not yet owned.
/// Used by /load/index for retroactive cosmetic reconciliation. Card counts are NOT mutated.
/// </summary>
Task<CardGrantResult> BackfillCosmeticsAsync(long viewerId);
}

View File

@@ -377,6 +377,7 @@ public class StoryService : IStoryService
// Load viewer with all collections RewardGrantService might mutate. Split-query
// to avoid the cartesian-explode pitfall (CLAUDE.md "EF split query").
var viewer = await _db.Viewers
.Include(v => v.Cards).ThenInclude(c => c.Card)
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.LeaderSkins)
@@ -388,10 +389,11 @@ public class StoryService : IStoryService
foreach (var r in chapter.Rewards)
{
GrantedReward granted;
IReadOnlyList<GrantedReward> granted;
try
{
granted = _rewards.Apply(viewer, (UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
granted = await _rewards.ApplyAsync(
viewer, (UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
}
catch (NotSupportedException ex)
{
@@ -407,16 +409,19 @@ public class StoryService : IStoryService
// balances (e.g. UserRupyCount = num).
// - story_reward_list: deltas. Client (ResultAnimationAgent
// .HandleStoryAndMissionRewards) feeds each entry to
// AddReward(item) which draws a "+N received" line in
// the rewards popup.
// Same reward_id, different reward_num. For cosmetics (binary owned/not-owned)
// both happen to be 1, so the bug only surfaces on currency rewards.
resp.RewardList.Add(new RewardGrant
// AddReward(item) which draws a "+N received" popup line.
// ApplyAsync may return 1+N entries (Card grants cascade into cosmetics). All
// post-state entries go into reward_list; story_reward_list only gets the
// top-level mission row's delta (cascade cosmetics have no corresponding row).
foreach (var g in granted)
{
RewardType = granted.RewardType.ToString(),
RewardId = granted.RewardId.ToString(),
RewardNum = granted.RewardNum.ToString(),
});
resp.RewardList.Add(new RewardGrant
{
RewardType = g.RewardType.ToString(),
RewardId = g.RewardId.ToString(),
RewardNum = g.RewardNum.ToString(),
});
}
resp.StoryRewardList.Add(new RewardGrant
{
RewardType = ((int)r.RewardType).ToString(),

View File

@@ -79,41 +79,4 @@ public class PackRepositoryTests
}
}
[Test]
public async Task GrantCardsToViewer_inserts_new_and_increments_existing()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
// Cards are not seeded by BaseDataSeeder (they come from CardImport). Insert one directly.
const long seedCardId = 100000001L;
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.Cards.Add(new ShadowverseCardEntry { Id = seedCardId, Name = "Test Card", Rarity = Rarity.Bronze });
await db.SaveChangesAsync();
}
long sampleCardId;
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
sampleCardId = await db.Cards.OrderBy(c => c.Id).Select(c => c.Id).FirstAsync();
}
using (var scope = factory.Services.CreateScope())
{
var repo = scope.ServiceProvider.GetRequiredService<IPackRepository>();
await repo.GrantCardsToViewer(viewerId, new[] { sampleCardId, sampleCardId });
}
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var v = await db.Viewers.Include(x => x.Cards).ThenInclude(c => c.Card)
.FirstAsync(x => x.Id == viewerId);
var owned = v.Cards.Single(c => c.Card.Id == sampleCardId);
Assert.That(owned.Count, Is.EqualTo(2));
}
}
}

View File

@@ -35,8 +35,8 @@ public class CardAcquisitionServiceTests
viewer.Cards.Add(new OwnedCardEntry { Card = card, Count = count, IsProtected = false });
}
// Pre-seed bare Cards rows (no ownership) for any cardIds the test plans to grant via
// the service. GrantCardsToViewer does a FirstAsync(c => c.Id == grpKey) lookup; without
// these the production code throws "Sequence contains no elements".
// the service. RewardGrantService.ApplyAsync does FirstOrDefaultAsync on _db.Cards;
// without the row the grant throws InvalidOperationException("Card {id} not in catalog").
if (grantableCardIds is not null)
{
foreach (var cardId in grantableCardIds)
@@ -71,7 +71,7 @@ public class CardAcquisitionServiceTests
}
[Test]
public async Task GrantAsync_NewBronzeCard_GrantsCardOnly()
public async Task GrantManyAsync_NewBronzeCard_GrantsCardOnly()
{
// 101111010 is a synthetic test card (inserted ad-hoc via grantableCardIds) with no
// CardCosmeticReward associations. Expectation: grant returns only the type=5 entry.
@@ -79,7 +79,7 @@ public class CardAcquisitionServiceTests
var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 101111010L });
var service = GetService(factory);
var result = await service.GrantAsync(viewerId, new[] { 101111010L });
var result = await service.GrantManyAsync(viewerId, new[] { 101111010L });
Assert.That(result.RewardList, Has.Count.EqualTo(1));
Assert.That(result.RewardList[0].RewardType, Is.EqualTo(5)); // Card
@@ -88,7 +88,7 @@ public class CardAcquisitionServiceTests
}
[Test]
public async Task GrantAsync_LeaderCard_GrantsCardAndSkin()
public async Task GrantManyAsync_LeaderCard_GrantsCardAndSkin()
{
// Card 704741010 (Aria leader-card variant) has 3 cosmetic associations in the seed:
// skin 407, sleeve 704741010, emblem 704741010.
@@ -116,7 +116,7 @@ public class CardAcquisitionServiceTests
}
var service = GetService(factory);
var result = await service.GrantAsync(viewerId, new[] { 704741010L });
var result = await service.GrantManyAsync(viewerId, new[] { 704741010L });
var skinEntry = result.RewardList.SingleOrDefault(r => r.RewardType == 10);
Assert.That(skinEntry, Is.Not.Null, "expected a Skin reward entry");
@@ -134,7 +134,7 @@ public class CardAcquisitionServiceTests
}
[Test]
public async Task GrantAsync_AlreadyOwnedSkin_OmitsFromRewardList()
public async Task GrantManyAsync_AlreadyOwnedSkin_OmitsFromRewardList()
{
using var factory = new SVSimTestFactory();
var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741010L });
@@ -153,7 +153,7 @@ public class CardAcquisitionServiceTests
}
var service = GetService(factory);
var result = await service.GrantAsync(viewerId, new[] { 704741010L });
var result = await service.GrantManyAsync(viewerId, new[] { 704741010L });
Assert.That(result.RewardList.Any(r => r.RewardType == 10), Is.False,
"skin entry should be omitted since viewer already owns it");
@@ -162,7 +162,7 @@ public class CardAcquisitionServiceTests
}
[Test]
public async Task GrantAsync_FoilLeaderCard_ResolvesToNonFoilCosmetics()
public async Task GrantManyAsync_FoilLeaderCard_ResolvesToNonFoilCosmetics()
{
using var factory = new SVSimTestFactory();
var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741011L });
@@ -181,7 +181,7 @@ public class CardAcquisitionServiceTests
}
var service = GetService(factory);
var result = await service.GrantAsync(viewerId, new[] { 704741011L });
var result = await service.GrantManyAsync(viewerId, new[] { 704741011L });
var skinEntry = result.RewardList.SingleOrDefault(r => r.RewardType == 10);
Assert.That(skinEntry, Is.Not.Null, "expected skin entry via foil resolution");
@@ -195,7 +195,7 @@ public class CardAcquisitionServiceTests
}
[Test]
public async Task GrantAsync_MultipleCopiesOfSameLeader_GrantsCosmeticOnce()
public async Task GrantManyAsync_MultipleCopiesOfSameLeader_GrantsCosmeticOnce()
{
using var factory = new SVSimTestFactory();
var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741010L });
@@ -210,7 +210,7 @@ public class CardAcquisitionServiceTests
}
var service = GetService(factory);
var result = await service.GrantAsync(viewerId, new[] { 704741010L, 704741010L, 704741010L });
var result = await service.GrantManyAsync(viewerId, new[] { 704741010L, 704741010L, 704741010L });
Assert.That(result.RewardList.Count(r => r.RewardType == 10), Is.EqualTo(1),
"skin should appear exactly once in reward_list");
@@ -219,7 +219,7 @@ public class CardAcquisitionServiceTests
}
[Test]
public async Task GrantAsync_RecentLeaderCard_GrantsAllFiveCosmeticTypes()
public async Task GrantManyAsync_RecentLeaderCard_GrantsAllFiveCosmeticTypes()
{
using var factory = new SVSimTestFactory();
var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 721141010L });
@@ -250,7 +250,7 @@ public class CardAcquisitionServiceTests
}
var service = GetService(factory);
var result = await service.GrantAsync(viewerId, new[] { 721141010L });
var result = await service.GrantManyAsync(viewerId, new[] { 721141010L });
Assert.Multiple(() =>
{
@@ -263,7 +263,7 @@ public class CardAcquisitionServiceTests
}
[Test]
public async Task GrantAsync_BackfillMode_DoesNotIncrementCardCount()
public async Task BackfillCosmeticsAsync_DoesNotIncrementCardCount()
{
using var factory = new SVSimTestFactory();
// Pre-seed viewer with card 704741010 count=5, no skin
@@ -279,7 +279,7 @@ public class CardAcquisitionServiceTests
}
var service = GetService(factory);
var result = await service.GrantAsync(viewerId, newCardIds: null);
var result = await service.BackfillCosmeticsAsync(viewerId);
using var scope2 = factory.Services.CreateScope();
var db2 = scope2.ServiceProvider.GetRequiredService<SVSimDbContext>();
@@ -295,7 +295,7 @@ public class CardAcquisitionServiceTests
}
[Test]
public async Task GrantAsync_BackfillCalledTwice_SecondCallIsNoOp()
public async Task BackfillCosmeticsAsync_CalledTwice_SecondCallIsNoOp()
{
using var factory = new SVSimTestFactory();
var viewerId = await SeedViewerWithCards(factory, new() { [704741010L] = 1 });
@@ -310,15 +310,15 @@ public class CardAcquisitionServiceTests
}
var service = GetService(factory);
var first = await service.GrantAsync(viewerId, newCardIds: null);
var second = await service.GrantAsync(viewerId, newCardIds: null);
var first = await service.BackfillCosmeticsAsync(viewerId);
var second = await service.BackfillCosmeticsAsync(viewerId);
Assert.That(first.RewardList, Is.Not.Empty, "first call should grant cosmetics");
Assert.That(second.RewardList, Is.Empty, "second call should be a no-op");
}
[Test]
public async Task GrantAsync_LeaderCardWithMissingMapping_GrantsCardSilently()
public async Task GrantManyAsync_LeaderCardWithMissingMapping_GrantsCardSilently()
{
using var factory = new SVSimTestFactory();
var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 701141010L });
@@ -326,7 +326,7 @@ public class CardAcquisitionServiceTests
// NO CardCosmeticReward rows inserted for this card — simulates the 83 missing-mapping cases.
var service = GetService(factory);
var result = await service.GrantAsync(viewerId, new[] { 701141010L });
var result = await service.GrantManyAsync(viewerId, new[] { 701141010L });
Assert.That(result.RewardList.Any(r => r.RewardType == 5 && r.RewardId == 701141010L), Is.True);
Assert.That(result.RewardList.Any(r => r.RewardType == 10), Is.False);
@@ -334,7 +334,7 @@ public class CardAcquisitionServiceTests
}
[Test]
public async Task GrantAsync_OrphanCosmeticReward_LogsWarningAndSkips()
public async Task GrantManyAsync_OrphanCosmeticReward_LogsWarningAndSkips()
{
using var factory = new SVSimTestFactory();
var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741010L });
@@ -354,7 +354,7 @@ public class CardAcquisitionServiceTests
}
var service = GetService(factory);
var result = await service.GrantAsync(viewerId, new[] { 704741010L });
var result = await service.GrantManyAsync(viewerId, new[] { 704741010L });
Assert.That(result.RewardList.Any(r => r.RewardType == 5 && r.RewardId == 704741010L), Is.True);
Assert.That(result.RewardList.Any(r => r.RewardType == 10 && r.RewardId == 407L), Is.True,

View File

@@ -18,23 +18,22 @@ public class RewardGrantServiceTests
using var scope = factory.Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
// Pick an Id above the seeded sleeves.csv range so this test doesn't collide with the
// reference-CSV importer SVSimTestFactory runs at host construction.
const int testSleeveId = 2_000_000_000;
var sleeve = new SleeveEntry { Id = testSleeveId }; // SleeveEntry has no Name field; Id only
var sleeve = new SleeveEntry { Id = testSleeveId };
ctx.Sleeves.Add(sleeve);
await ctx.SaveChangesAsync();
var viewer = await ctx.Viewers.Include(v => v.Sleeves).FirstAsync(v => v.Id == viewerId);
var svc = scope.ServiceProvider.GetRequiredService<RewardGrantService>();
var entry = svc.Apply(viewer, UserGoodsType.Sleeve, detailId: testSleeveId, num: 1);
var result = await svc.ApplyAsync(viewer, UserGoodsType.Sleeve, detailId: testSleeveId, num: 1);
await ctx.SaveChangesAsync();
Assert.That(result, Has.Count.EqualTo(1));
Assert.That(viewer.Sleeves.Any(s => s.Id == testSleeveId), Is.True);
Assert.That(entry.RewardType, Is.EqualTo((int)UserGoodsType.Sleeve));
Assert.That(entry.RewardId, Is.EqualTo((long)testSleeveId));
Assert.That(entry.RewardNum, Is.EqualTo(1));
Assert.That(result[0].RewardType, Is.EqualTo((int)UserGoodsType.Sleeve));
Assert.That(result[0].RewardId, Is.EqualTo((long)testSleeveId));
Assert.That(result[0].RewardNum, Is.EqualTo(1));
}
[Test]
@@ -50,12 +49,12 @@ public class RewardGrantServiceTests
var svc = scope.ServiceProvider.GetRequiredService<RewardGrantService>();
// Reward grants 50; final balance becomes 150 and reward_num on the wire is the new total.
var entry = svc.Apply(viewer, UserGoodsType.Rupy, detailId: 0, num: 50);
var result = await svc.ApplyAsync(viewer, UserGoodsType.Rupy, detailId: 0, num: 50);
await ctx.SaveChangesAsync();
Assert.That(viewer.Currency.Rupees, Is.EqualTo(150UL));
Assert.That(entry.RewardNum, Is.EqualTo(150));
Assert.That(result, Has.Count.EqualTo(1));
Assert.That(result[0].RewardNum, Is.EqualTo(150));
}
[Test]
@@ -66,8 +65,6 @@ public class RewardGrantServiceTests
using var scope = factory.Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
// Pick an Id above the seeded leaderskins.csv range so this test doesn't collide with
// the reference-CSV importer SVSimTestFactory runs at host construction.
const int testSkinId = 9_999_999;
ctx.LeaderSkins.Add(new LeaderSkinEntry { Id = testSkinId, Name = "Round1Reward" });
await ctx.SaveChangesAsync();
@@ -75,25 +72,180 @@ public class RewardGrantServiceTests
var viewer = await ctx.Viewers.Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId);
var svc = scope.ServiceProvider.GetRequiredService<RewardGrantService>();
svc.Apply(viewer, UserGoodsType.Skin, testSkinId, 1);
svc.Apply(viewer, UserGoodsType.Skin, testSkinId, 1); // second grant is a no-op on collection size
await svc.ApplyAsync(viewer, UserGoodsType.Skin, testSkinId, 1);
await svc.ApplyAsync(viewer, UserGoodsType.Skin, testSkinId, 1);
await ctx.SaveChangesAsync();
Assert.That(viewer.LeaderSkins.Count(s => s.Id == testSkinId), Is.EqualTo(1));
}
[Test]
public async Task Card_reward_throws_NotSupported()
public async Task Card_fresh_grant_inserts_owned_entry_and_returns_post_state_count()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var scope = factory.Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
const long testCardId = 999_001_001L;
ctx.Cards.Add(new ShadowverseCardEntry { Id = testCardId, Name = "RGSTestCard", Rarity = Rarity.Bronze });
await ctx.SaveChangesAsync();
var viewer = await ctx.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId);
var svc = scope.ServiceProvider.GetRequiredService<RewardGrantService>();
var result = await svc.ApplyAsync(viewer, UserGoodsType.Card, testCardId, 1);
await ctx.SaveChangesAsync();
Assert.That(result, Has.Count.EqualTo(1));
Assert.That(result[0].RewardType, Is.EqualTo((int)UserGoodsType.Card));
Assert.That(result[0].RewardId, Is.EqualTo(testCardId));
Assert.That(result[0].RewardNum, Is.EqualTo(1));
Assert.That(viewer.Cards.Single(c => c.Card.Id == testCardId).Count, Is.EqualTo(1));
}
[Test]
public async Task Card_existing_grant_increments_count()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var scope = factory.Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
const long testCardId = 999_001_002L;
var card = new ShadowverseCardEntry { Id = testCardId, Name = "RGSTestCard2", Rarity = Rarity.Bronze };
ctx.Cards.Add(card);
var viewer = await ctx.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId);
viewer.Cards.Add(new OwnedCardEntry { Card = card, Count = 2, IsProtected = false });
await ctx.SaveChangesAsync();
var svc = scope.ServiceProvider.GetRequiredService<RewardGrantService>();
var result = await svc.ApplyAsync(viewer, UserGoodsType.Card, testCardId, 1);
await ctx.SaveChangesAsync();
Assert.That(result, Has.Count.EqualTo(1));
Assert.That(result[0].RewardNum, Is.EqualTo(3));
Assert.That(viewer.Cards.Single(c => c.Card.Id == testCardId).Count, Is.EqualTo(3));
}
[Test]
public async Task Card_with_cascade_rows_emits_card_plus_cosmetics()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var scope = factory.Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
const long testCardId = 999_002_010L;
const int testSkinId = 999_002_011;
ctx.Cards.Add(new ShadowverseCardEntry { Id = testCardId, Name = "CascadeTestCard", Rarity = Rarity.Gold });
ctx.LeaderSkins.Add(new LeaderSkinEntry { Id = testSkinId, Name = "CascadeTestSkin" });
ctx.CardCosmeticRewards.Add(new CardCosmeticReward
{
CardId = testCardId, Type = CosmeticType.Skin, CosmeticId = testSkinId, Quantity = 1,
});
await ctx.SaveChangesAsync();
var viewer = await ctx.Viewers
.Include(v => v.Cards).ThenInclude(c => c.Card)
.Include(v => v.LeaderSkins)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
var svc = scope.ServiceProvider.GetRequiredService<RewardGrantService>();
var result = await svc.ApplyAsync(viewer, UserGoodsType.Card, testCardId, 1);
await ctx.SaveChangesAsync();
Assert.That(result, Has.Count.EqualTo(2));
Assert.That(result.Any(r => r.RewardType == (int)UserGoodsType.Card && r.RewardId == testCardId), Is.True);
Assert.That(result.Any(r => r.RewardType == (int)UserGoodsType.Skin && r.RewardId == testSkinId), Is.True);
Assert.That(viewer.LeaderSkins.Any(s => s.Id == testSkinId), Is.True);
}
[Test]
public async Task Card_cascade_skips_already_owned_cosmetic()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var scope = factory.Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
const long testCardId = 999_002_020L;
const int testSkinId = 999_002_021;
ctx.Cards.Add(new ShadowverseCardEntry { Id = testCardId, Name = "CascadeOwnedTestCard", Rarity = Rarity.Gold });
var skin = new LeaderSkinEntry { Id = testSkinId, Name = "CascadeOwnedTestSkin" };
ctx.LeaderSkins.Add(skin);
ctx.CardCosmeticRewards.Add(new CardCosmeticReward
{
CardId = testCardId, Type = CosmeticType.Skin, CosmeticId = testSkinId, Quantity = 1,
});
await ctx.SaveChangesAsync();
var viewer = await ctx.Viewers
.Include(v => v.Cards).ThenInclude(c => c.Card)
.Include(v => v.LeaderSkins)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
viewer.LeaderSkins.Add(skin);
await ctx.SaveChangesAsync();
var svc = scope.ServiceProvider.GetRequiredService<RewardGrantService>();
var result = await svc.ApplyAsync(viewer, UserGoodsType.Card, testCardId, 1);
await ctx.SaveChangesAsync();
Assert.That(result, Has.Count.EqualTo(1));
Assert.That(result[0].RewardType, Is.EqualTo((int)UserGoodsType.Card));
Assert.That(result[0].RewardId, Is.EqualTo(testCardId));
}
[Test]
public async Task Card_foil_grant_resolves_cascade_to_non_foil_id()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var scope = factory.Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
const long nonFoilId = 999_002_030L;
const long foilId = 999_002_031L;
const int testSkinId = 999_002_032;
ctx.Cards.Add(new ShadowverseCardEntry { Id = nonFoilId, Name = "FoilCascadeBase", Rarity = Rarity.Gold });
ctx.Cards.Add(new ShadowverseCardEntry { Id = foilId, Name = "FoilCascadeFoil", Rarity = Rarity.Gold, IsFoil = true });
ctx.LeaderSkins.Add(new LeaderSkinEntry { Id = testSkinId, Name = "FoilCascadeSkin" });
ctx.CardCosmeticRewards.Add(new CardCosmeticReward
{
CardId = nonFoilId, Type = CosmeticType.Skin, CosmeticId = testSkinId, Quantity = 1,
});
await ctx.SaveChangesAsync();
var viewer = await ctx.Viewers
.Include(v => v.Cards).ThenInclude(c => c.Card)
.Include(v => v.LeaderSkins)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
var svc = scope.ServiceProvider.GetRequiredService<RewardGrantService>();
var result = await svc.ApplyAsync(viewer, UserGoodsType.Card, foilId, 1);
await ctx.SaveChangesAsync();
Assert.That(result.Any(r => r.RewardType == (int)UserGoodsType.Card && r.RewardId == foilId), Is.True);
Assert.That(result.Any(r => r.RewardType == (int)UserGoodsType.Skin && r.RewardId == testSkinId), Is.True);
}
[Test]
public async Task SpotCard_still_throws_NotSupported()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var scope = factory.Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var viewer = await ctx.Viewers.FirstAsync(v => v.Id == viewerId);
var svc = scope.ServiceProvider.GetRequiredService<RewardGrantService>();
Assert.Throws<NotSupportedException>(() =>
svc.Apply(viewer, UserGoodsType.Card, 10001001L, 1));
Assert.ThrowsAsync<NotSupportedException>(async () =>
await svc.ApplyAsync(viewer, UserGoodsType.SpotCard, 1L, 1));
Assert.ThrowsAsync<NotSupportedException>(async () =>
await svc.ApplyAsync(viewer, UserGoodsType.SpotCardOnlyLatestCardPack, 1L, 1));
}
}

View File

@@ -28,7 +28,7 @@ public class StoryServiceTests
_viewer = new Mock<IViewerStoryProgressRepository>();
// Non-reward tests never exercise the DB/reward path; use a stub InMemory context.
var db = StoryServiceTestHelpers.NewInMemoryDb(nameof(SetUp));
var rewards = new RewardGrantService(db);
var rewards = new RewardGrantService(db, NullLogger<RewardGrantService>.Instance);
_service = new StoryService(
_master.Object, _viewer.Object,
rewards: rewards,
@@ -394,6 +394,137 @@ public class StoryServiceTests
Assert.That(freshViewer.Currency.RedEther, Is.EqualTo(100UL));
}
}
[Test]
public async Task FinishAsync_play_shape_first_clear_grants_card_and_cascades_cosmetic()
{
using var factory = new SVSimTestFactory();
const long testCardId = 998_001_010L;
const int testSkinId = 998_001_011;
const int testStoryId = 998_001_500;
using (var seedScope = factory.Services.CreateScope())
{
var db = seedScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.Cards.Add(new ShadowverseCardEntry { Id = testCardId, Name = "StoryCascadeCard", Rarity = SVSim.Database.Enums.Rarity.Gold });
db.LeaderSkins.Add(new LeaderSkinEntry { Id = testSkinId, Name = "StoryCascadeSkin" });
db.CardCosmeticRewards.Add(new CardCosmeticReward
{
CardId = testCardId,
Type = SVSim.Database.Enums.CosmeticType.Skin,
CosmeticId = testSkinId,
Quantity = 1,
});
await db.SaveChangesAsync();
}
var svc = NewServiceWithSeededViewer(factory, out var scope, out var viewerId);
using (scope)
{
var chapter = Ch(testStoryId, 1, 2, "1", "2");
chapter.Rewards.Add(new StoryChapterReward
{
RewardType = 5, // UserGoodsType.Card
RewardDetailId = testCardId,
RewardNumber = 1,
});
_master.Setup(m => m.GetChapterByIdAsync(testStoryId)).ReturnsAsync(chapter);
_viewer.Setup(v => v.GetProgressForChaptersAsync(viewerId, It.IsAny<IEnumerable<int>>()))
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress>());
var req = new FinishRequest { StoryId = testStoryId, IsFinish = 1, ClassId = 2 };
var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId);
// reward_list (post-state) gets BOTH the Card entry AND the cascaded Skin entry.
Assert.That(resp.RewardList.Any(r => r.RewardType == "5" && r.RewardId == testCardId.ToString()), Is.True,
"card reward should appear in reward_list");
Assert.That(resp.RewardList.Any(r => r.RewardType == "10" && r.RewardId == testSkinId.ToString()), Is.True,
"cascade skin should appear in reward_list");
// story_reward_list (deltas) only carries the top-level chapter reward.
Assert.That(resp.StoryRewardList.Count(r => r.RewardType == "5"), Is.EqualTo(1));
Assert.That(resp.StoryRewardList.Any(r => r.RewardType == "10"), Is.False,
"cascade cosmetics should not appear in story_reward_list deltas");
}
// Verify viewer ownership was persisted.
using var verifyScope = factory.Services.CreateScope();
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var viewer = await verifyDb.Viewers
.Include(v => v.Cards).ThenInclude(c => c.Card)
.Include(v => v.LeaderSkins)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
Assert.That(viewer.Cards.Any(c => c.Card.Id == testCardId), Is.True);
Assert.That(viewer.LeaderSkins.Any(s => s.Id == testSkinId), Is.True);
}
[Test]
public async Task FinishAsync_card_grant_for_already_owned_card_increments_not_duplicates()
{
using var factory = new SVSimTestFactory();
const long testCardId = 998_002_010L;
const int testStoryId = 998_002_500;
// Pre-seed the card in the catalog AND give the viewer 2 copies of it before the story finish.
using (var seedScope = factory.Services.CreateScope())
{
var db = seedScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.Cards.Add(new ShadowverseCardEntry
{
Id = testCardId,
Name = "ExistingOwnedCard",
Rarity = SVSim.Database.Enums.Rarity.Silver,
});
await db.SaveChangesAsync();
}
var svc = NewServiceWithSeededViewer(factory, out var scope, out var viewerId);
using (scope)
{
// Seed 2 owned copies of the card under the same viewer used by NewServiceWithSeededViewer.
var scopeDb = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var seedViewer = await scopeDb.Viewers
.Include(v => v.Cards).ThenInclude(c => c.Card)
.FirstAsync(v => v.Id == viewerId);
var card = await scopeDb.Cards.FirstAsync(c => c.Id == testCardId);
seedViewer.Cards.Add(new OwnedCardEntry { Card = card, Count = 2, IsProtected = false });
await scopeDb.SaveChangesAsync();
// Configure a chapter that grants 1 copy of the same card.
var chapter = Ch(testStoryId, 1, 2, "1", "2");
chapter.Rewards.Add(new StoryChapterReward
{
RewardType = 5, // UserGoodsType.Card
RewardDetailId = testCardId,
RewardNumber = 1,
});
_master.Setup(m => m.GetChapterByIdAsync(testStoryId)).ReturnsAsync(chapter);
_viewer.Setup(v => v.GetProgressForChaptersAsync(viewerId, It.IsAny<IEnumerable<int>>()))
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress>());
var req = new FinishRequest { StoryId = testStoryId, IsFinish = 1, ClassId = 2 };
var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId);
// Post-state count on the wire should be 3 (2 owned + 1 granted).
var cardEntry = resp.RewardList.SingleOrDefault(r => r.RewardType == "5" && r.RewardId == testCardId.ToString());
Assert.That(cardEntry, Is.Not.Null, "card reward should appear in reward_list");
Assert.That(cardEntry!.RewardNum, Is.EqualTo("3"), "post-state count should be incremented, not reset to 1");
}
// Verify the viewer has exactly ONE OwnedCardEntry row for this card, with Count=3.
using var verifyScope = factory.Services.CreateScope();
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var viewer = await verifyDb.Viewers
.Include(v => v.Cards).ThenInclude(c => c.Card)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
var ownedRows = viewer.Cards.Where(c => c.Card.Id == testCardId).ToList();
Assert.That(ownedRows, Has.Count.EqualTo(1), "exactly one OwnedCardEntry row should exist (no duplicates)");
Assert.That(ownedRows[0].Count, Is.EqualTo(3));
}
}
internal static class StoryServiceTestHelpers