Final shop family. Schema additions:
- ViewerCurrency.SpotPoints (ulong) — new currency column on Viewers.
- SpotCardExchangeEntry — catalog (distinct from the pre-existing
SpotCardEntry, which is the /load/index rental-cost concept).
- ViewerSpotCardExchange — standalone composite-PK table tracking
(viewer, card, exchanged_at, is_pre_release_snapshot). Standalone
avoids cartesian-explode on viewer-graph reads.
RewardGrantService gains a SpotCardPoint=12 currency case mirroring
the RedEther/Crystal pattern. Doc comment refreshed; SpotCard=11 and
SpotCardOnlyLatestCardPack=13 remain unimplemented with explanatory
NotSupportedException — captures show emitters always use Card=5 with
the spot-card-specific id.
Controller:
- /top: emits exactly 9 clan buckets [{"1": [cards]}, ...] matching
prod's arbitrary single-key shape. exchange_status per-card (0=
available, 1=already-exchanged, 2=LimitOver after pre-release cap).
pre_relase_info WIRE TYPO PRESERVED ("relase" not "release").
- /exchange: server-authoritative price (client-supplied
exchange_point ignored); debits SpotPoints with post-state-total
reward_list entry; grants card via RewardGrantService.ApplyAsync
(cosmetic cascade included); persists ViewerSpotCardExchange row.
Insufficient points / already-exchanged / pre-release-limit all
return 400 without partial state.
LoadController now populates /load/index spot_point from
viewer.Currency.SpotPoints (was always 0).
PreReleaseLimit hardcoded to 2 matching capture; promote to GameConfig
when captures show variance.
504 tests pass (was 496; +8 spot-card-exchange tests).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
222 lines
10 KiB
C#
222 lines
10 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
using SVSim.Database.Enums;
|
|
using SVSim.Database.Models;
|
|
|
|
namespace SVSim.Database.Services;
|
|
|
|
/// <summary>
|
|
/// 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>
|
|
/// Single canonical grant primitive for every <see cref="UserGoodsType"/> the server hands to a
|
|
/// viewer. Switch on the type, mutate the appropriate viewer collection / <see cref="ViewerCurrency"/>
|
|
/// field, return the wire-shape entries to embed in the response's <c>reward_list</c>.
|
|
///
|
|
/// <para>
|
|
/// <b>DO NOT reimplement reward dispatch in a controller or new helper.</b> This service handles
|
|
/// RedEther, Crystal, SpotCardPoint, Item, Card (with <see cref="CardCosmeticReward"/> cascade),
|
|
/// Sleeve, Emblem, Degree, Rupy, Skin, MyPageBG — everything except the dead-letter SpotCard /
|
|
/// SpotCardOnlyLatestCardPack slots (use Card=5 instead). Endpoint code that takes a
|
|
/// list of <c>(type, id, num)</c> tuples should iterate and call <see cref="ApplyAsync"/>
|
|
/// per tuple — never switch on type yourself, never filter to "only card-typed rewards", never
|
|
/// build a second dispatch table. Past duplicate implementations (ICardAcquisitionService in the
|
|
/// EmulatedEntrypoint project, the first pass at /build_deck/buy) all silently dropped subsets of
|
|
/// types and produced the same bug: wire reward visible but viewer's collection unchanged. When a
|
|
/// new reward type comes up, add a case here. See <c>feedback_reward_grant_service</c> memory.
|
|
/// </para>
|
|
///
|
|
/// 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;
|
|
private readonly ILogger<RewardGrantService> _log;
|
|
|
|
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 Single(type, detailId, 1);
|
|
|
|
case UserGoodsType.Emblem:
|
|
AddCosmeticIfMissing(viewer.Emblems, detailId, _db.Emblems);
|
|
return Single(type, detailId, 1);
|
|
|
|
case UserGoodsType.Skin: // LeaderSkin in our schema
|
|
AddCosmeticIfMissing(viewer.LeaderSkins, detailId, _db.LeaderSkins);
|
|
return Single(type, detailId, 1);
|
|
|
|
case UserGoodsType.Degree:
|
|
AddCosmeticIfMissing(viewer.Degrees, detailId, _db.Degrees);
|
|
return Single(type, detailId, 1);
|
|
|
|
case UserGoodsType.MyPageBG:
|
|
AddCosmeticIfMissing(viewer.MyPageBackgrounds, detailId, _db.MyPageBackgrounds);
|
|
return Single(type, detailId, 1);
|
|
|
|
case UserGoodsType.Rupy:
|
|
viewer.Currency.Rupees += (ulong)num;
|
|
return Single(type, detailId, checked((int)viewer.Currency.Rupees));
|
|
|
|
case UserGoodsType.Crystal:
|
|
viewer.Currency.Crystals += (ulong)num;
|
|
return Single(type, detailId, checked((int)viewer.Currency.Crystals));
|
|
|
|
case UserGoodsType.RedEther:
|
|
viewer.Currency.RedEther += (ulong)num;
|
|
return Single(type, detailId, checked((int)viewer.Currency.RedEther));
|
|
|
|
case UserGoodsType.SpotCardPoint:
|
|
viewer.Currency.SpotPoints += (ulong)num;
|
|
return Single(type, detailId, checked((int)viewer.Currency.SpotPoints));
|
|
|
|
case UserGoodsType.Item:
|
|
{
|
|
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId);
|
|
if (owned is null)
|
|
{
|
|
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 Single(type, detailId, num);
|
|
}
|
|
owned.Count += num;
|
|
return Single(type, detailId, owned.Count);
|
|
}
|
|
|
|
case UserGoodsType.Card:
|
|
return await ApplyCardAsync(viewer, detailId, num, ct);
|
|
|
|
case UserGoodsType.SpotCard:
|
|
case UserGoodsType.SpotCardOnlyLatestCardPack:
|
|
// Spot-card-typed grants don't appear in captures — emitters always use Card=5
|
|
// with the spot-card-specific id. These two enum slots remain unimplemented; if a
|
|
// capture ever shows one in a reward_list we'll know to wire them up here.
|
|
throw new NotSupportedException(
|
|
$"{type} rewards are not yet supported — emitters use Card=5 instead.");
|
|
|
|
default:
|
|
throw new NotSupportedException($"UserGoodsType {type} not yet handled by RewardGrantService");
|
|
}
|
|
}
|
|
|
|
private async Task<IReadOnlyList<GrantedReward>> ApplyCardAsync(
|
|
Viewer viewer, long cardId, int num, CancellationToken ct)
|
|
{
|
|
// 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;
|
|
|
|
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>
|
|
/// Reflectively reads an entity's Id property — works for both <c>BaseEntity<int></c>
|
|
/// (cosmetics) and <c>BaseEntity<long></c> (e.g. Viewer/Card) without forcing two
|
|
/// non-generic overloads of <see cref="AddCosmeticIfMissing"/>.
|
|
/// </summary>
|
|
private static long GetId<T>(T e)
|
|
{
|
|
var prop = typeof(T).GetProperty("Id")
|
|
?? throw new InvalidOperationException($"Type {typeof(T).Name} missing Id property");
|
|
var val = prop.GetValue(e);
|
|
return val switch { long l => l, int i => i, _ => 0 };
|
|
}
|
|
}
|