Consolidation
This commit is contained in:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user