Additional card content
This commit is contained in:
@@ -13,6 +13,7 @@ using SVSim.EmulatedEntrypoint.Infrastructure;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
@@ -41,14 +42,17 @@ public class LoadController : SVSimController
|
||||
private readonly ICardRepository _cardRepository;
|
||||
private readonly ICollectionRepository _collectionRepository;
|
||||
private readonly IGlobalsRepository _globalsRepository;
|
||||
private readonly ICardAcquisitionService _acquisition;
|
||||
|
||||
public LoadController(IViewerRepository viewerRepository, ICardRepository cardRepository,
|
||||
ICollectionRepository collectionRepository, IGlobalsRepository globalsRepository)
|
||||
ICollectionRepository collectionRepository, IGlobalsRepository globalsRepository,
|
||||
ICardAcquisitionService acquisition)
|
||||
{
|
||||
_viewerRepository = viewerRepository;
|
||||
_cardRepository = cardRepository;
|
||||
_collectionRepository = collectionRepository;
|
||||
_globalsRepository = globalsRepository;
|
||||
_acquisition = acquisition;
|
||||
}
|
||||
|
||||
[HttpPost("index")]
|
||||
@@ -66,6 +70,18 @@ public class LoadController : SVSimController
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Backfill any card-associated cosmetics the viewer should already own. Idempotent.
|
||||
// We MUST re-fetch the viewer after this call because GetViewerByShortUdid uses
|
||||
// .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);
|
||||
viewer = await _viewerRepository.GetViewerByShortUdid(shortUdid);
|
||||
if (viewer is null)
|
||||
{
|
||||
return NotFound(); // defensive — should never happen
|
||||
}
|
||||
|
||||
// user_card_list policy (see docs/api-spec/endpoints/post-login/load-index.md
|
||||
// §user_card_list for the full discussion):
|
||||
//
|
||||
|
||||
@@ -27,19 +27,22 @@ public class PackController : SVSimController
|
||||
private readonly ICardPoolProvider _pools;
|
||||
private readonly IRandom _rng;
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly ICardAcquisitionService _acquisition;
|
||||
|
||||
public PackController(
|
||||
IPackRepository packs,
|
||||
PackOpenService opener,
|
||||
ICardPoolProvider pools,
|
||||
IRandom rng,
|
||||
SVSimDbContext db)
|
||||
SVSimDbContext db,
|
||||
ICardAcquisitionService acquisition)
|
||||
{
|
||||
_packs = packs;
|
||||
_opener = opener;
|
||||
_pools = pools;
|
||||
_rng = rng;
|
||||
_db = db;
|
||||
_acquisition = acquisition;
|
||||
}
|
||||
|
||||
[HttpPost("info")]
|
||||
@@ -193,16 +196,15 @@ 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);
|
||||
await _packs.GrantCardsToViewer(viewerId, draw.Cards.Select(c => c.CardId));
|
||||
var grant = await _acquisition.GrantAsync(viewerId, draw.Cards.Select(c => c.CardId));
|
||||
|
||||
// Build reward_list with post-state totals. The client's PlayerStaticData.UpdateHaveUserGoodsNum
|
||||
// does direct assignment (`UserRupyCount = reward_num`, owned-count = reward_num), so we
|
||||
// emit the new totals — not deltas. Without these the on-screen rupee/crystal/collection
|
||||
// counts stay stale until the next /mypage/refresh or restart.
|
||||
// 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
|
||||
// controller — it's a pack-purchase concern, not a card-grant concern. The client's
|
||||
// PlayerStaticData.UpdateHaveUserGoodsNum does direct assignment, so currency/card counts
|
||||
// must be the new TOTAL — emitting deltas would leave the on-screen balances stale.
|
||||
var rewardList = new List<RewardListEntry>();
|
||||
var postViewer = await _db.Viewers
|
||||
.Include(v => v.Cards).ThenInclude(c => c.Card)
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
var postViewer = await _db.Viewers.FirstAsync(v => v.Id == viewerId);
|
||||
|
||||
if (child.TypeDetail == 2)
|
||||
{
|
||||
@@ -212,12 +214,7 @@ public class PackController : SVSimController
|
||||
{
|
||||
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)postViewer.Currency.Rupees });
|
||||
}
|
||||
|
||||
var drawnCardIds = draw.Cards.Select(c => c.CardId).Distinct().ToHashSet();
|
||||
foreach (var owned in postViewer.Cards.Where(c => drawnCardIds.Contains(c.Card.Id)))
|
||||
{
|
||||
rewardList.Add(new RewardListEntry { RewardType = 5, RewardId = owned.Card.Id, RewardNum = owned.Count });
|
||||
}
|
||||
rewardList.AddRange(grant.RewardList);
|
||||
|
||||
return new PackOpenResponse
|
||||
{
|
||||
|
||||
1069
SVSim.EmulatedEntrypoint/Data/card_cosmetic_rewards.csv
Normal file
1069
SVSim.EmulatedEntrypoint/Data/card_cosmetic_rewards.csv
Normal file
File diff suppressed because it is too large
Load Diff
@@ -72,6 +72,7 @@ public class Program
|
||||
.GetGameConfiguration("default").GetAwaiter().GetResult().Config);
|
||||
builder.Services.AddScoped<ICardPoolProvider, DbCardPoolProvider>();
|
||||
builder.Services.AddScoped<PackOpenService>();
|
||||
builder.Services.AddScoped<ICardAcquisitionService, CardAcquisitionService>();
|
||||
builder.Services.AddSingleton<IRandom, SystemRandom>();
|
||||
|
||||
#endregion
|
||||
|
||||
149
SVSim.EmulatedEntrypoint/Services/CardAcquisitionService.cs
Normal file
149
SVSim.EmulatedEntrypoint/Services/CardAcquisitionService.cs
Normal file
@@ -0,0 +1,149 @@
|
||||
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.EmulatedEntrypoint.Models.Dtos;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
public class CardAcquisitionService : ICardAcquisitionService
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly IPackRepository _packs;
|
||||
private readonly ILogger<CardAcquisitionService> _log;
|
||||
|
||||
public CardAcquisitionService(SVSimDbContext db, IPackRepository packs, ILogger<CardAcquisitionService> log)
|
||||
{
|
||||
_db = db; _packs = packs; _log = log;
|
||||
}
|
||||
|
||||
public async Task<CardGrantResult> GrantAsync(long viewerId, IEnumerable<long>? newCardIds = null)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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))
|
||||
{
|
||||
rewardList.Add(new RewardListEntry
|
||||
{
|
||||
RewardType = (int)reward.Type,
|
||||
RewardId = reward.CosmeticId,
|
||||
RewardNum = reward.Quantity,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
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)
|
||||
{
|
||||
var id = (int)reward.CosmeticId; // master tables use int Id
|
||||
|
||||
switch (reward.Type)
|
||||
{
|
||||
case CosmeticType.Skin:
|
||||
{
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
13
SVSim.EmulatedEntrypoint/Services/CardGrantResult.cs
Normal file
13
SVSim.EmulatedEntrypoint/Services/CardGrantResult.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Output of <see cref="ICardAcquisitionService.GrantAsync"/>. The RewardList is wire-shape:
|
||||
/// pass directly into a /pack/open or similar response's <c>data.reward_list</c> field.
|
||||
///
|
||||
/// In grant mode, contains one type=5 (Card) entry per distinct newCardId with post-state
|
||||
/// count, plus one entry per newly-granted cosmetic.
|
||||
/// In backfill mode, contains only cosmetic entries (no card-count entries).
|
||||
/// </summary>
|
||||
public record CardGrantResult(IReadOnlyList<RewardListEntry> RewardList);
|
||||
18
SVSim.EmulatedEntrypoint/Services/ICardAcquisitionService.cs
Normal file
18
SVSim.EmulatedEntrypoint/Services/ICardAcquisitionService.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
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.
|
||||
/// </summary>
|
||||
Task<CardGrantResult> GrantAsync(long viewerId, IEnumerable<long>? newCardIds = null);
|
||||
}
|
||||
Reference in New Issue
Block a user