Files
SVSimServer/SVSim.EmulatedEntrypoint/Controllers/GiftController.cs
gamer147 f1d881b26a fix(gift): drop RowVersion (SQLite incompatible) + restore wire reward_type map
[Timestamp] byte[] doesn't work under SQLite (the test backend) — EF
expects the DB to populate it on insert, but SQLite has no equivalent
of Postgres's xmin. The WHERE Status = Unclaimed filter plus
IInventoryService's viewer-level concurrency is the practical defense;
RowVersion was only a backstop. Regenerated the migration without the
RowVersion column.

Wire reward_type on the gift endpoint uses a gift-specific scheme that
diverges from UserGoodsType for currencies: wire 1 = Crystal (enum=2),
wire 9 = Rupy (enum=9), wire 4 = Item (enum=4). A naked cast resolves
wire 1 to UserGoodsType.RedEther and silently grants the wrong wallet
— restored the explicit WireRewardTypeToUserGoodsType map from the old
tutorial controller.

Retrofits existing GiftControllerTests to call SeedTutorialPresentsAsync
on the new helper (RegisterViewer doesn't auto-seed; only the prod
signup path does). All 7 existing tests pass.
2026-06-08 20:44:52 -04:00

199 lines
8.5 KiB
C#

using System.Globalization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Mapping;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Gift;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Gift;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// Persistent gift inbox. /gift/top + /tutorial/gift_top are pure URL aliases over the
/// same ViewerPresent query; /gift/receive_gift + /tutorial/gift_receive share a single
/// ReceiveImpl whose only divergence is the route-gated tutorial-state bump.
///
/// Tutorial gifts are seeded as real ViewerPresent rows during /tool/signup
/// (see ViewerRepository.RegisterAnonymousViewer) — this controller carries no static
/// gift catalog.
/// </summary>
public class GiftController : SVSimController
{
private const int PageSize = 30;
private const int GiftReceiveTutorialStep = 41;
private readonly SVSimDbContext _db;
private readonly IInventoryService _inv;
public GiftController(SVSimDbContext db, IInventoryService inv)
{
_db = db;
_inv = inv;
}
[HttpPost("/gift/top")]
[HttpPost("/tutorial/gift_top")]
public async Task<ActionResult<GiftTopResponse>> Top([FromBody] GiftTopRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var (unclaimed, history) = await ReadTopWindowAsync(viewerId, request.Page);
return new GiftTopResponse
{
PresentList = unclaimed.Select(PresentMapper.ToWire).ToList(),
PresentHistoryList = history.Select(PresentMapper.ToWire).ToList(),
LimitOverPresentList = new(), // expiration sweep deferred — always [] for now
};
}
[HttpPost("/gift/receive_gift")]
public Task<ActionResult<GiftReceiveResponse>> Receive([FromBody] GiftReceiveRequest r)
=> ReceiveImpl(r, advanceTutorial: false);
[HttpPost("/tutorial/gift_receive")]
public Task<ActionResult<GiftReceiveResponse>> TutorialReceive([FromBody] GiftReceiveRequest r)
=> ReceiveImpl(r, advanceTutorial: true);
private async Task<ActionResult<GiftReceiveResponse>> ReceiveImpl(
GiftReceiveRequest request, bool advanceTutorial)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var requested = request.PresentIdArray.ToHashSet();
var state = request.State; // 1 = MAIL_READ (claim), 3 = MAIL_DELETE
// Pull only currently-Unclaimed rows matching the request — already-Claimed /
// Deleted / Expired rows are silently ignored (idempotent retry semantics).
var targets = await _db.ViewerPresents
.Where(p => p.ViewerId == viewerId
&& p.Status == PresentStatus.Unclaimed
&& requested.Contains(p.PresentId))
.ToListAsync();
await using var tx = await _inv.BeginAsync(viewerId, configure:
cfg => cfg.WithInclude(v => v.MissionData));
var rewardListEntries = new List<GiftRewardListEntry>();
var now = DateTime.UtcNow;
foreach (var p in targets)
{
if (state == 1)
{
// Wire reward_type on the gift endpoint follows a gift-specific scheme that
// diverges from UserGoodsType for currencies: wire "1" means Crystal (enum=2),
// wire "9" means Rupy (enum=9), wire "4" means Item (enum=4). A naked cast would
// resolve wire 1 -> UserGoodsType.RedEther and silently grant the wrong wallet.
var granted = await tx.GrantAsync(
WireRewardTypeToUserGoodsType(p.RewardType),
p.RewardDetailId,
(int)p.RewardCount);
// reward_list carries POST-STATE TOTALS (client does direct assignment).
// See project_wire_reward_list_post_state. GrantAsync already returns post-state.
if (granted.Count > 0)
{
rewardListEntries.Add(new GiftRewardListEntry
{
RewardType = p.RewardType.ToString(CultureInfo.InvariantCulture),
RewardId = p.RewardDetailId.ToString(CultureInfo.InvariantCulture),
RewardNum = granted[0].RewardNum.ToString(CultureInfo.InvariantCulture),
});
}
p.Status = PresentStatus.Claimed;
p.ClaimedAt = now;
}
else if (state == 3)
{
// MAIL_DELETE: no grant, no reward_list entry, no history. Tombstone the
// row so re-deletes are idempotent under the same WHERE-Unclaimed filter.
p.Status = PresentStatus.Deleted;
p.ClaimedAt = now; // overload as "decided-at" — tombstone never reaches wire
}
}
// Tutorial step advance — route-gated, no Source/state checks. Preserve-max so
// replays don't downgrade viewers already past 41.
if (advanceTutorial && tx.Viewer.MissionData.TutorialState < GiftReceiveTutorialStep)
tx.Viewer.MissionData.TutorialState = GiftReceiveTutorialStep;
await tx.CommitAsync(); // throws DbUpdateConcurrencyException on RowVersion conflict
// Rebuild the inbox window (page 1) — the client wipes its local lists and rebuilds
// from these.
var (unclaimed, history) = await ReadTopWindowAsync(viewerId, page: 1);
// is_unreceived_present drives the home-screen inbox badge — must be the DB count
// post-commit, NOT hardcoded false (hiding the badge after partial claims).
var stillUnclaimed = await _db.ViewerPresents
.AnyAsync(p => p.ViewerId == viewerId && p.Status == PresentStatus.Unclaimed);
return new GiftReceiveResponse
{
CardList = new(), // capture is []; reward_list carries the grants
// Echo only ids actually transitioned by THIS call — NOT requested ids, which
// would re-fire the "received N gifts" popup on replay.
ReceivedIds = targets
.Select(t => t.PresentId)
.OrderBy(x => x, StringComparer.Ordinal)
.ToList(),
// Per-gift summary for the "+N received" popup. Empty on state=3.
TotalReceiveCountList = (state == 1 ? targets : Enumerable.Empty<ViewerPresent>())
.Select(t => new TotalReceiveCountDto
{
RewardType = t.RewardType,
RewardDetailId = t.RewardDetailId,
RewardCount = t.RewardCount,
ItemType = t.ItemType ?? 0,
IsUsable = true,
}).ToList(),
PresentList = unclaimed.Select(PresentMapper.ToWire).ToList(),
PresentHistoryList = history.Select(PresentMapper.ToWire).ToList(),
IsUnreceivedPresent = stillUnclaimed,
RewardList = rewardListEntries,
// Echo persisted state, not a hardcoded 41 — preserve-max above keeps it stable.
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 async Task<(List<ViewerPresent> Unclaimed, List<ViewerPresent> History)> ReadTopWindowAsync(
long viewerId, int page)
{
int pageOneIndexed = Math.Max(1, page);
int skip = (pageOneIndexed - 1) * PageSize;
// Unclaimed: chronological (oldest first — capture order matches this).
var unclaimed = await _db.ViewerPresents
.Where(p => p.ViewerId == viewerId && p.Status == PresentStatus.Unclaimed)
.OrderBy(p => p.CreatedAt).ThenBy(p => p.Id)
.Skip(skip).Take(PageSize)
.ToListAsync();
// History: most-recent-first (standard inbox UX).
var history = await _db.ViewerPresents
.Where(p => p.ViewerId == viewerId && p.Status == PresentStatus.Claimed)
.OrderByDescending(p => p.ClaimedAt).ThenByDescending(p => p.Id)
.Skip(skip).Take(PageSize)
.ToListAsync();
return (unclaimed, history);
}
}