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

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