Files
SVSimServer/SVSim.EmulatedEntrypoint/Controllers/GiftController.cs
2026-06-08 20:35:42 -04:00

216 lines
10 KiB
C#

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Gift;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Gift;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// Tutorial-scoped gift endpoints. We do NOT implement a generic gift system here —
/// only the /tutorial/gift_top and /tutorial/gift_receive aliases needed for the
/// step 31 → 41 reward flow. A full gift inbox is future work; if/when needed,
/// add /gift/top and /gift/receive_gift aliases to this controller.
/// </summary>
public class GiftController : SVSimController
{
/// <summary>The hardcoded tutorial gift bundle every fresh viewer sees at step 31.</summary>
public static readonly IReadOnlyList<PresentDto> TutorialGifts = new[]
{
new PresentDto { PresentId = "71478626", RewardType = "1", RewardDetailId = "0", RewardCount = "400", Message = "For completing the tutorial" },
new PresentDto { PresentId = "71478627", RewardType = "9", RewardDetailId = "0", RewardCount = "100", Message = "For completing the tutorial" },
new PresentDto { PresentId = "71478628", RewardType = "4", RewardDetailId = "1", RewardCount = "3", Message = "For completing the tutorial", ItemType = 1 },
new PresentDto { PresentId = "71478629", RewardType = "4", RewardDetailId = "80001", RewardCount = "40", Message = "For completing the tutorial", ItemType = 2 },
new PresentDto { PresentId = "71478630", RewardType = "4", RewardDetailId = "90001", RewardCount = "1", Message = "For completing the tutorial", ItemType = 2 },
};
private readonly SVSimDbContext _db;
private readonly IInventoryService _inv;
public GiftController(SVSimDbContext db, IInventoryService inv)
{
_db = db;
_inv = inv;
}
[HttpPost("/tutorial/gift_top")]
public async Task<ActionResult<GiftTopResponse>> TutorialGiftTop([FromBody] GiftTopRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var claimedList = await _db.ViewerClaimedTutorialGifts
.Where(g => g.ViewerId == viewerId)
.Select(g => g.PresentId)
.ToListAsync();
var claimed = new HashSet<string>(claimedList);
var nowString = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss");
var presents = TutorialGifts
.Where(p => !claimed.Contains(p.PresentId))
.Select(p => Clone(p, nowString))
.ToList();
var history = TutorialGifts
.Where(p => claimed.Contains(p.PresentId))
.Select(p => Clone(p, nowString))
.ToList();
return new GiftTopResponse
{
PresentList = presents,
PresentHistoryList = history,
LimitOverPresentList = new(),
};
}
[HttpPost("/tutorial/gift_receive")]
public async Task<ActionResult<GiftReceiveResponse>> TutorialGiftReceive([FromBody] GiftReceiveRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var requestedIds = request.PresentIdArray.ToHashSet();
// Resolve which of the requested ids are still claimable for this viewer before opening tx.
var alreadyClaimedList = await _db.ViewerClaimedTutorialGifts
.Where(g => g.ViewerId == viewerId && requestedIds.Contains(g.PresentId))
.Select(g => g.PresentId)
.ToListAsync();
var alreadyClaimed = new HashSet<string>(alreadyClaimedList);
var toClaim = TutorialGifts
.Where(p => requestedIds.Contains(p.PresentId) && !alreadyClaimed.Contains(p.PresentId))
.ToList();
// Open inventory tx with MissionData loaded for tutorial-step advance.
await using var tx = await _inv.BeginAsync(viewerId, configure:
cfg => cfg.WithInclude(v => v.MissionData));
// Apply grants via tx. Collect post-state per (type, detailId) for reward_list.
// Each GrantAsync returns a list of GrantedReward with post-state totals; for currencies
// only one entry is returned; for cards the cascade may return more entries (card + cosmetics).
// reward_list must carry post-state totals (client does direct assignment).
var rewardListEntries = new List<GiftRewardListEntry>();
foreach (var p in toClaim)
{
var goodsType = WireRewardTypeToUserGoodsType(int.Parse(p.RewardType));
var granted = await tx.GrantAsync(goodsType, long.Parse(p.RewardDetailId), int.Parse(p.RewardCount));
// Use the first granted entry's post-state for the top-level gift reward_list entry.
// Gift rewards are currencies and items only (no cards in TutorialGifts), so granted
// always has exactly one element. The post-state total is already correct from tx.
if (granted.Count > 0)
{
rewardListEntries.Add(new GiftRewardListEntry
{
RewardType = p.RewardType,
RewardId = p.RewardDetailId,
RewardNum = granted[0].RewardNum.ToString(System.Globalization.CultureInfo.InvariantCulture),
});
}
}
// Advance tutorial state from 31 → 41 as a side-effect of this claim (no separate
// /tutorial/update → 41 call appears in the prod capture). Preserve max — don't downgrade
// viewers who are already past step 41.
const int GiftReceiveTutorialStep = 41;
if (tx.Viewer.MissionData.TutorialState < GiftReceiveTutorialStep)
{
tx.Viewer.MissionData.TutorialState = GiftReceiveTutorialStep;
}
// Persist claim receipts inside the same tx.
var now = DateTime.UtcNow;
foreach (var p in toClaim)
{
_db.ViewerClaimedTutorialGifts.Add(new SVSim.Database.Models.ViewerClaimedTutorialGift
{
ViewerId = viewerId,
PresentId = p.PresentId,
ClaimedAt = now,
});
}
await tx.CommitAsync();
var nowString = now.ToString("yyyy-MM-dd HH:mm:ss");
var allClaimedList = await _db.ViewerClaimedTutorialGifts
.Where(g => g.ViewerId == viewerId)
.Select(g => g.PresentId)
.ToListAsync();
var allClaimed = new HashSet<string>(allClaimedList);
// Derive presentList/historyList up front so IsUnreceivedPresent can read the count
// without re-filtering. unclaimedPresents are the gifts still on offer after this call;
// claimedPresents are everything the viewer has ever received (this call + prior calls).
var unclaimedPresents = TutorialGifts
.Where(p => !allClaimed.Contains(p.PresentId))
.Select(p => Clone(p, nowString))
.ToList();
var claimedPresents = TutorialGifts
.Where(p => allClaimed.Contains(p.PresentId))
.Select(p => Clone(p, nowString))
.ToList();
return new GiftReceiveResponse
{
CardList = new(),
// Echo only the ids actually granted by THIS call. Building this from `requestedIds`
// would falsely confirm a re-grant on idempotent retries: the client would re-show
// the "received N gifts" popup and direct-assign the same post-state totals it already
// applied, double-toasting the user. Sort ascending to match the prod-capture order.
ReceivedIds = toClaim
.Select(p => p.PresentId)
.OrderBy(x => x)
.ToList(),
// Same idempotency contract: only the gifts granted in THIS call belong in the
// per-reward summary list. The client uses this to drive the +N popups.
TotalReceiveCountList = toClaim
.Select(p => new TotalReceiveCountDto
{
RewardType = int.Parse(p.RewardType),
RewardDetailId = long.Parse(p.RewardDetailId),
RewardCount = long.Parse(p.RewardCount),
ItemType = p.ItemType ?? 0,
IsUsable = true,
}).ToList(),
PresentList = unclaimedPresents,
PresentHistoryList = claimedPresents,
// True when there are still unclaimed gifts on offer — drives the inbox badge state.
// Hardcoding false hid the badge after partial claims even though present_list still
// carried unclaimed entries.
IsUnreceivedPresent = unclaimedPresents.Count > 0,
// reward_list entries carry POST-STATE TOTALS (from tx.GrantAsync).
// See project memory: project_wire_reward_list_post_state.
// Iterate toClaim so idempotent re-receive doesn't re-emit post-state entries.
RewardList = rewardListEntries,
// Echo the persisted state, not a hardcoded 41. The state may already be past 41
// for replay/edge-case calls (the Math.Max-preserve block above keeps it stable);
// emitting 41 anyway would surface a regressed step to the client and desync the
// tutorial-state machine.
TutorialStep = tx.Viewer.MissionData.TutorialState,
};
}
private static UserGoodsType WireRewardTypeToUserGoodsType(int wireType) => wireType switch
{
1 => UserGoodsType.Crystal,
4 => UserGoodsType.Item,
9 => UserGoodsType.Rupy,
_ => throw new InvalidOperationException($"Unmapped gift wire reward_type {wireType}"),
};
private static PresentDto Clone(PresentDto p, string createTime) => new()
{
PresentId = p.PresentId,
RewardType = p.RewardType,
RewardDetailId = p.RewardDetailId,
RewardCount = p.RewardCount,
ConditionNumber = p.ConditionNumber,
PresentLimitType = p.PresentLimitType,
RewardLimitTime = p.RewardLimitTime,
CreateTime = createTime,
ItemType = p.ItemType,
Message = p.Message,
};
}