Consolidation
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user