This commit is contained in:
gamer147
2026-05-25 12:03:47 -04:00
parent d067f8a64a
commit 558e8288eb
44 changed files with 6512 additions and 3 deletions

View File

@@ -0,0 +1,118 @@
using Microsoft.EntityFrameworkCore;
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.
/// </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.
///
/// 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.
/// </summary>
public sealed class RewardGrantService
{
private readonly SVSimDbContext _db;
public RewardGrantService(SVSimDbContext db) => _db = db;
public GrantedReward Apply(Viewer viewer, UserGoodsType type, long detailId, int num)
{
switch (type)
{
case UserGoodsType.Sleeve:
AddCosmeticIfMissing(viewer.Sleeves, detailId, _db.Sleeves);
return new GrantedReward((int)type, detailId, 1);
case UserGoodsType.Emblem:
AddCosmeticIfMissing(viewer.Emblems, detailId, _db.Emblems);
return new GrantedReward((int)type, detailId, 1);
case UserGoodsType.Skin: // LeaderSkin in our schema
AddCosmeticIfMissing(viewer.LeaderSkins, detailId, _db.LeaderSkins);
return new GrantedReward((int)type, detailId, 1);
case UserGoodsType.Degree:
AddCosmeticIfMissing(viewer.Degrees, detailId, _db.Degrees);
return new GrantedReward((int)type, detailId, 1);
case UserGoodsType.MyPageBG:
AddCosmeticIfMissing(viewer.MyPageBackgrounds, detailId, _db.MyPageBackgrounds);
return new GrantedReward((int)type, detailId, 1);
case UserGoodsType.Rupy:
viewer.Currency.Rupees += (ulong)num;
return new GrantedReward((int)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));
case UserGoodsType.RedEther:
viewer.Currency.RedEther += (ulong)num;
return new GrantedReward((int)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 new GrantedReward((int)type, detailId, num);
}
owned.Count += num;
return new GrantedReward((int)type, detailId, owned.Count);
}
case UserGoodsType.Card:
case UserGoodsType.SpotCard:
case UserGoodsType.SpotCardOnlyLatestCardPack:
throw new NotSupportedException(
$"{type} rewards are out of Phase 1 scope — extend RewardGrantService when /pack/open or similar needs them.");
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
{
// 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;
// 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);
}
/// <summary>
/// Reflectively reads an entity's Id property — works for both <c>BaseEntity&lt;int&gt;</c>
/// (cosmetics) and <c>BaseEntity&lt;long&gt;</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 };
}
}