using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using SVSim.Database.Enums; using SVSim.Database.Models; namespace SVSim.Database.Services; /// /// Wire-shape entry returned by . Field names match /// the reward_list entries used by /pack/open, /basic_puzzle/finish, and /// /story/*/finish. reward_num is a POST-STATE TOTAL for currencies and a count for /// collection grants — see . /// public sealed record GrantedReward(int RewardType, long RewardId, int RewardNum); /// /// Single canonical grant primitive for every the server hands to a /// viewer. Switch on the type, mutate the appropriate viewer collection / /// field, return the wire-shape entries to embed in the response's reward_list. /// /// /// DO NOT reimplement reward dispatch in a controller or new helper. This service handles /// RedEther, Crystal, Item, Card (with cascade), Sleeve, Emblem, /// Degree, Rupy, Skin, MyPageBG — everything except SpotCard (TODO). Endpoint code that takes a /// list of (type, id, num) tuples should iterate and call /// 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 feedback_reward_grant_service memory. /// /// /// Card grants additionally run the 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 — /// this service only mutates the in-memory graph so a controller can stack several grants in /// a single transaction. /// public sealed class RewardGrantService { private readonly SVSimDbContext _db; private readonly ILogger _log; public RewardGrantService(SVSimDbContext db, ILogger log) { _db = db; _log = log; } public async Task> 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.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: // 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 not yet supported — see SpotCard TODO in RewardGrantService."); default: throw new NotSupportedException($"UserGoodsType {type} not yet handled by RewardGrantService"); } } private async Task> 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 { 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 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(List collection, long detailId, DbSet 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; } /// /// Reflectively reads an entity's Id property — works for both BaseEntity<int> /// (cosmetics) and BaseEntity<long> (e.g. Viewer/Card) without forcing two /// non-generic overloads of . /// private static long GetId(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 }; } }