feat(item-purchase): /item_purchase/{info,purchase} + catalog

Schema: ItemPurchaseCatalogEntry (single table). Per-viewer quota tracked
via existing ViewerEventCounter keyed by "item_purchase:<id>" with period
JstPeriod.MonthKey when IsMonthlyReset else AllTime.

Controller:
- /info returns catalog + per-period rest (server-computed
  max(0, PurchaseLimit - counter)) + user_card_pack_ticket_list (every
  Items.Type==2 row joined to viewer count, zeros included — client
  unconditionally UpdateItemNum's each entry).
- /purchase: sold_out check before currency check (no counter increment
  on currency failure), inline TryDebit covers RedEther/Crystal/Rupy/Item
  with post-state-total reward_list entry, grant via RewardGrantService.
  Request `rest` accepted but ignored (server counter is canonical).

Importer mirrors PaymentItemImporter shape — idempotent find-or-create,
seed-missing rows preserved. 3 entries from the prod capture.

486 tests pass (was 476; +10 item_purchase tests).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-27 22:41:02 -04:00
parent f237851e42
commit 559a170957
16 changed files with 4354 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
[
{
"purchase_id": 1,
"require_item_type": 1,
"require_item_id": 0,
"require_item_num": 5000,
"purchase_item_type": 4,
"purchase_item_id": 1000,
"purchase_item_num": 1,
"purchase_name": "[b]One Time Only![/b] Seer's Globe x1",
"is_monthly_reset": false,
"purchase_limit": 1
},
{
"purchase_id": 100002,
"require_item_type": 4,
"require_item_id": 1001,
"require_item_num": 5,
"purchase_item_type": 4,
"purchase_item_id": 1000,
"purchase_item_num": 1,
"purchase_name": "",
"is_monthly_reset": true,
"purchase_limit": 3
},
{
"purchase_id": 100003,
"require_item_type": 1,
"require_item_id": 0,
"require_item_num": 30000,
"purchase_item_type": 4,
"purchase_item_id": 1000,
"purchase_item_num": 1,
"purchase_name": "",
"is_monthly_reset": true,
"purchase_limit": 10
}
]

View File

@@ -0,0 +1,59 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Bootstrap.Models.Seed;
using SVSim.Database;
using SVSim.Database.Models;
namespace SVSim.Bootstrap.Importers;
/// <summary>
/// Idempotent upsert of the item-purchase catalog from <c>seeds/item-purchase.json</c>.
/// Source is the wire <c>/item_purchase/info</c> response, extracted via
/// <c>data_dumps/extract/extract-item-purchase.py</c>. Rows missing from the seed are LEFT INTACT.
/// </summary>
public class ItemPurchaseImporter
{
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
{
string path = Path.Combine(seedDir, "item-purchase.json");
var seed = SeedLoader.LoadList<ItemPurchaseSeed>(path);
if (seed.Count == 0)
{
Console.WriteLine("[ItemPurchaseImporter] No seed rows; skipping.");
return 0;
}
var existing = await context.ItemPurchaseCatalog.ToDictionaryAsync(e => e.Id);
int created = 0, updated = 0;
foreach (var s in seed)
{
if (s.PurchaseId == 0) continue;
var entry = existing.TryGetValue(s.PurchaseId, out var ex)
? ex : new ItemPurchaseCatalogEntry { Id = s.PurchaseId };
entry.RequireItemType = s.RequireItemType;
entry.RequireItemId = s.RequireItemId;
entry.RequireItemNum = s.RequireItemNum;
entry.PurchaseItemType = s.PurchaseItemType;
entry.PurchaseItemId = s.PurchaseItemId;
entry.PurchaseItemNum = s.PurchaseItemNum;
entry.PurchaseName = s.PurchaseName;
entry.IsMonthlyReset = s.IsMonthlyReset;
entry.PurchaseLimit = s.PurchaseLimit;
entry.IsEnabled = true;
if (ex is null)
{
context.ItemPurchaseCatalog.Add(entry);
existing[s.PurchaseId] = entry;
created++;
}
else updated++;
}
await context.SaveChangesAsync();
Console.WriteLine($"[ItemPurchaseImporter] +{created}/~{updated}");
return created + updated;
}
}

View File

@@ -0,0 +1,17 @@
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
public sealed class ItemPurchaseSeed
{
[JsonPropertyName("purchase_id")] public int PurchaseId { get; set; }
[JsonPropertyName("require_item_type")] public int RequireItemType { get; set; }
[JsonPropertyName("require_item_id")] public long RequireItemId { get; set; }
[JsonPropertyName("require_item_num")] public int RequireItemNum { get; set; }
[JsonPropertyName("purchase_item_type")] public int PurchaseItemType { get; set; }
[JsonPropertyName("purchase_item_id")] public long PurchaseItemId { get; set; }
[JsonPropertyName("purchase_item_num")] public int PurchaseItemNum { get; set; }
[JsonPropertyName("purchase_name")] public string PurchaseName { get; set; } = "";
[JsonPropertyName("is_monthly_reset")] public bool IsMonthlyReset { get; set; }
[JsonPropertyName("purchase_limit")] public int PurchaseLimit { get; set; }
}

View File

@@ -99,6 +99,7 @@ public static class Program
await new PaymentItemImporter().ImportAsync(context, opts.SeedDir); await new PaymentItemImporter().ImportAsync(context, opts.SeedDir);
await new ItemImporter().ImportAsync(context, opts.SeedDir); await new ItemImporter().ImportAsync(context, opts.SeedDir);
await new SleeveShopImporter().ImportAsync(context, opts.SeedDir); await new SleeveShopImporter().ImportAsync(context, opts.SeedDir);
await new ItemPurchaseImporter().ImportAsync(context, opts.SeedDir);
var puzzleImporter = new PuzzleImporter(); var puzzleImporter = new PuzzleImporter();
await puzzleImporter.ImportGroupsAsync(context, opts.SeedDir); await puzzleImporter.ImportGroupsAsync(context, opts.SeedDir);
await puzzleImporter.ImportPuzzlesAsync(context, opts.SeedDir); await puzzleImporter.ImportPuzzlesAsync(context, opts.SeedDir);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddItemPurchaseCatalog : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ItemPurchaseCatalog",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false),
RequireItemType = table.Column<int>(type: "integer", nullable: false),
RequireItemId = table.Column<long>(type: "bigint", nullable: false),
RequireItemNum = table.Column<int>(type: "integer", nullable: false),
PurchaseItemType = table.Column<int>(type: "integer", nullable: false),
PurchaseItemId = table.Column<long>(type: "bigint", nullable: false),
PurchaseItemNum = table.Column<int>(type: "integer", nullable: false),
PurchaseName = table.Column<string>(type: "text", nullable: false),
IsMonthlyReset = table.Column<bool>(type: "boolean", nullable: false),
PurchaseLimit = table.Column<int>(type: "integer", nullable: false),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ItemPurchaseCatalog", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ItemPurchaseCatalog");
}
}
}

View File

@@ -1107,6 +1107,53 @@ namespace SVSim.Database.Migrations
b.ToTable("Items"); b.ToTable("Items");
}); });
modelBuilder.Entity("SVSim.Database.Models.ItemPurchaseCatalogEntry", b =>
{
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DateUpdated")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<bool>("IsMonthlyReset")
.HasColumnType("boolean");
b.Property<long>("PurchaseItemId")
.HasColumnType("bigint");
b.Property<int>("PurchaseItemNum")
.HasColumnType("integer");
b.Property<int>("PurchaseItemType")
.HasColumnType("integer");
b.Property<int>("PurchaseLimit")
.HasColumnType("integer");
b.Property<string>("PurchaseName")
.IsRequired()
.HasColumnType("text");
b.Property<long>("RequireItemId")
.HasColumnType("bigint");
b.Property<int>("RequireItemNum")
.HasColumnType("integer");
b.Property<int>("RequireItemType")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("ItemPurchaseCatalog");
});
modelBuilder.Entity("SVSim.Database.Models.LeaderSkinEntry", b => modelBuilder.Entity("SVSim.Database.Models.LeaderSkinEntry", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")

View File

@@ -0,0 +1,39 @@
using SVSim.Database.Common;
namespace SVSim.Database.Models;
/// <summary>
/// One row of the /item_purchase/info catalog — an exchange the user can perform N times per
/// period (monthly or lifetime) by spending <c>RequireItem*</c> to acquire <c>PurchaseItem*</c>.
/// PK = wire <c>purchase_id</c>.
/// <para>
/// Both sides reference <see cref="Enums.UserGoodsType"/>. Captures show the common shape is
/// currency-for-item (RedEther 5000 → Seer's Globe ×1) or item-for-item (Orb Shard ×5 →
/// Seer's Globe ×1). Per-viewer remaining quota lives in
/// <see cref="ViewerEventCounter"/> keyed by <c>"item_purchase:{Id}"</c>.
/// </para>
/// </summary>
public class ItemPurchaseCatalogEntry : BaseEntity<int>
{
public int RequireItemType { get; set; }
public long RequireItemId { get; set; }
public int RequireItemNum { get; set; }
public int PurchaseItemType { get; set; }
public long PurchaseItemId { get; set; }
public int PurchaseItemNum { get; set; }
/// <summary>
/// SystemText-ready display name. May be empty — the client falls back to a templated name
/// built from <c>UserGoods.getUserGoodsName + count</c> via SystemText key "Shop_0132".
/// </summary>
public string PurchaseName { get; set; } = string.Empty;
/// <summary>True → quota resets at the start of each JST month. False → lifetime quota.</summary>
public bool IsMonthlyReset { get; set; }
/// <summary>Per-period purchase cap. Wire <c>rest</c> = max(0, PurchaseLimit - counter).</summary>
public int PurchaseLimit { get; set; }
public bool IsEnabled { get; set; }
}

View File

@@ -72,6 +72,7 @@ public class SVSimDbContext : DbContext
public DbSet<BuildDeckProductEntry> BuildDeckProducts => Set<BuildDeckProductEntry>(); public DbSet<BuildDeckProductEntry> BuildDeckProducts => Set<BuildDeckProductEntry>();
public DbSet<SleeveShopSeriesEntry> SleeveShopSeries => Set<SleeveShopSeriesEntry>(); public DbSet<SleeveShopSeriesEntry> SleeveShopSeries => Set<SleeveShopSeriesEntry>();
public DbSet<SleeveShopProductEntry> SleeveShopProducts => Set<SleeveShopProductEntry>(); public DbSet<SleeveShopProductEntry> SleeveShopProducts => Set<SleeveShopProductEntry>();
public DbSet<ItemPurchaseCatalogEntry> ItemPurchaseCatalog => Set<ItemPurchaseCatalogEntry>();
public DbSet<MaintenanceCardEntry> MaintenanceCards => Set<MaintenanceCardEntry>(); public DbSet<MaintenanceCardEntry> MaintenanceCards => Set<MaintenanceCardEntry>();
public DbSet<FeatureMaintenanceEntry> FeatureMaintenances => Set<FeatureMaintenanceEntry>(); public DbSet<FeatureMaintenanceEntry> FeatureMaintenances => Set<FeatureMaintenanceEntry>();
public DbSet<PreReleaseInfo> PreReleaseInfos => Set<PreReleaseInfo>(); public DbSet<PreReleaseInfo> PreReleaseInfos => Set<PreReleaseInfo>();

View File

@@ -0,0 +1,215 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ItemPurchase;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ItemPurchase;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// /item_purchase/* — the generic item shop where viewers spend item-or-currency to acquire
/// other items (e.g. RedEther → Seer's Globe, Orb Shards → Seer's Globe). Per-viewer monthly
/// or lifetime quota tracked via <see cref="ViewerEventCounter"/>.
/// </summary>
[Route("item_purchase")]
public class ItemPurchaseController : SVSimController
{
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
private readonly TimeProvider _time;
public ItemPurchaseController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time)
{
_db = db;
_rewards = rewards;
_time = time;
}
[HttpPost("info")]
public async Task<ActionResult<ItemPurchaseInfoResponse>> Info()
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var catalog = await _db.ItemPurchaseCatalog
.Where(c => c.IsEnabled)
.OrderBy(c => c.Id)
.ToListAsync();
var now = _time.GetUtcNow();
var monthKey = JstPeriod.MonthKey(now);
var keys = catalog.Select(c => CounterKey(c.Id)).ToList();
var counters = await _db.ViewerEventCounters
.Where(c => c.ViewerId == viewerId && keys.Contains(c.EventKey))
.ToListAsync();
var info = new List<ItemPurchaseEntryDto>(catalog.Count);
foreach (var c in catalog)
{
int count = CounterCount(counters, c, monthKey);
info.Add(new ItemPurchaseEntryDto
{
PurchaseId = c.Id,
RequireItemType = c.RequireItemType,
RequireItemId = c.RequireItemId,
RequireItemNum = c.RequireItemNum,
PurchaseItemType = c.PurchaseItemType,
PurchaseItemId = c.PurchaseItemId,
PurchaseItemNum = c.PurchaseItemNum,
PurchaseName = c.PurchaseName,
IsMonthlyReset = c.IsMonthlyReset ? 1 : 0,
Rest = Math.Max(0, c.PurchaseLimit - count),
});
}
// user_card_pack_ticket_list: every item with Type == 2 paired with the viewer's count
// (zero counts included — the client unconditionally calls UpdateItemNum per entry).
var ticketItems = await _db.Items
.Where(i => i.Type == 2)
.OrderByDescending(i => i.Id)
.ToListAsync();
var ownedByItemId = (await _db.Viewers
.Where(v => v.Id == viewerId)
.SelectMany(v => v.Items)
.Select(oi => new { oi.Item.Id, oi.Count })
.ToListAsync())
.ToDictionary(x => x.Id, x => x.Count);
var ticketList = ticketItems.Select(i => new UserCardPackTicketDto
{
ItemId = i.Id,
Number = ownedByItemId.TryGetValue(i.Id, out var cnt) ? cnt : 0,
}).ToList();
return new ItemPurchaseInfoResponse
{
ItemPurchaseInfo = info,
UserCardPackTicketList = ticketList,
};
}
[HttpPost("purchase")]
public async Task<ActionResult<ItemPurchasePurchaseResponse>> Purchase(ItemPurchasePurchaseRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var entry = await _db.ItemPurchaseCatalog.FindAsync(request.PurchaseId);
if (entry is null || !entry.IsEnabled)
return BadRequest(new { error = "unknown_purchase" });
var now = _time.GetUtcNow();
var period = entry.IsMonthlyReset ? JstPeriod.MonthKey(now) : JstPeriod.AllTime;
var key = CounterKey(entry.Id);
var counter = await _db.ViewerEventCounters
.FirstOrDefaultAsync(c => c.ViewerId == viewerId && c.EventKey == key && c.Period == period);
int currentCount = counter?.Count ?? 0;
int rest = entry.PurchaseLimit - currentCount;
if (rest <= 0)
return BadRequest(new { error = "sold_out" });
var viewer = await LoadViewerGraphAsync(viewerId);
var rewardList = new List<RewardListEntry>();
// Debit the require side. RewardGrantService is grant-only, so handle this inline.
var debit = TryDebit(viewer, (UserGoodsType)entry.RequireItemType, entry.RequireItemId, entry.RequireItemNum);
if (debit.Error is not null) return BadRequest(new { error = debit.Error });
if (debit.PostState is not null) rewardList.Add(debit.PostState);
// Grant the purchase side through the central dispatcher.
var granted = await _rewards.ApplyAsync(viewer,
(UserGoodsType)entry.PurchaseItemType, entry.PurchaseItemId, entry.PurchaseItemNum);
foreach (var g in granted)
{
rewardList.Add(new RewardListEntry
{
RewardType = g.RewardType,
RewardId = g.RewardId,
RewardNum = g.RewardNum,
});
}
// Increment the per-period counter.
if (counter is null)
{
_db.ViewerEventCounters.Add(new ViewerEventCounter
{
ViewerId = viewerId,
EventKey = key,
Period = period,
Count = 1,
});
}
else
{
counter.Count++;
}
await _db.SaveChangesAsync();
return new ItemPurchasePurchaseResponse { RewardList = rewardList };
}
/// <summary>
/// Debit <paramref name="num"/> of (<paramref name="type"/>, <paramref name="detailId"/>)
/// from the viewer, returning a post-state-aware <see cref="RewardListEntry"/> the client
/// uses to refresh its cached count. Returns an error string on insufficient balance.
/// </summary>
private static (RewardListEntry? PostState, string? Error) TryDebit(
Viewer viewer, UserGoodsType type, long detailId, int num)
{
switch (type)
{
case UserGoodsType.RedEther:
if (viewer.Currency.RedEther < (ulong)num)
return (null, "insufficient_red_ether");
viewer.Currency.RedEther -= (ulong)num;
return (new RewardListEntry { RewardType = 1, RewardId = 0, RewardNum = (int)viewer.Currency.RedEther }, null);
case UserGoodsType.Crystal:
if (viewer.Currency.Crystals < (ulong)num)
return (null, "insufficient_crystals");
viewer.Currency.Crystals -= (ulong)num;
return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)viewer.Currency.Crystals }, null);
case UserGoodsType.Rupy:
if (viewer.Currency.Rupees < (ulong)num)
return (null, "insufficient_rupees");
viewer.Currency.Rupees -= (ulong)num;
return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)viewer.Currency.Rupees }, null);
case UserGoodsType.Item:
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId);
if (owned is null || owned.Count < num)
return (null, "insufficient_item");
owned.Count -= num;
return (new RewardListEntry { RewardType = 4, RewardId = detailId, RewardNum = owned.Count }, null);
default:
return (null, $"debit_type_not_supported:{type}");
}
}
private static string CounterKey(int purchaseId) => $"item_purchase:{purchaseId}";
private static int CounterCount(List<ViewerEventCounter> counters, ItemPurchaseCatalogEntry entry, string monthKey)
{
var period = entry.IsMonthlyReset ? monthKey : JstPeriod.AllTime;
return counters.FirstOrDefault(c => c.EventKey == CounterKey(entry.Id) && c.Period == period)?.Count ?? 0;
}
private Task<Viewer> LoadViewerGraphAsync(long viewerId) => _db.Viewers
.Include(v => v.Items).ThenInclude(i => i.Item)
.Include(v => v.Cards).ThenInclude(c => c.Card)
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.LeaderSkins)
.Include(v => v.Degrees)
.Include(v => v.MyPageBackgrounds)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
}

View File

@@ -0,0 +1,22 @@
using System.Text.Json.Serialization;
using MessagePack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ItemPurchase;
/// <summary>
/// /item_purchase/purchase request body. <c>rest</c> is the client's locally-cached remaining
/// quota — used as an optional optimistic-concurrency check on the server. Not authoritative;
/// the server's own counter is canonical.
/// </summary>
[MessagePackObject]
public class ItemPurchasePurchaseRequest : BaseRequest
{
[JsonPropertyName("purchase_id")]
[Key("purchase_id")]
public int PurchaseId { get; set; }
[JsonPropertyName("rest")]
[Key("rest")]
public int Rest { get; set; }
}

View File

@@ -0,0 +1,86 @@
using System.Text.Json.Serialization;
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ItemPurchase;
/// <summary>
/// /item_purchase/info response.
/// <para>
/// <c>item_purchase_info</c> is an array of catalog entries with per-viewer <c>rest</c>
/// (PurchaseLimit minus the viewer's counter for the relevant period).
/// </para>
/// <para>
/// <c>user_card_pack_ticket_list</c> is the FULL set of card-pack-ticket items (catalog
/// Items.Type == 2) joined with the viewer's owned counts — even zero counts are emitted, as
/// the client's parser unconditionally calls <c>PlayerStaticData.UpdateItemNum(item_id, number)</c>
/// for every entry to refresh its in-memory mapping.
/// </para>
/// </summary>
[MessagePackObject]
public class ItemPurchaseInfoResponse
{
[JsonPropertyName("item_purchase_info")]
[Key("item_purchase_info")]
public List<ItemPurchaseEntryDto> ItemPurchaseInfo { get; set; } = new();
[JsonPropertyName("user_card_pack_ticket_list")]
[Key("user_card_pack_ticket_list")]
public List<UserCardPackTicketDto> UserCardPackTicketList { get; set; } = new();
}
[MessagePackObject]
public class ItemPurchaseEntryDto
{
[JsonPropertyName("purchase_id")]
[Key("purchase_id")]
public int PurchaseId { get; set; }
[JsonPropertyName("require_item_type")]
[Key("require_item_type")]
public int RequireItemType { get; set; }
[JsonPropertyName("require_item_id")]
[Key("require_item_id")]
public long RequireItemId { get; set; }
[JsonPropertyName("require_item_num")]
[Key("require_item_num")]
public int RequireItemNum { get; set; }
[JsonPropertyName("purchase_name")]
[Key("purchase_name")]
public string PurchaseName { get; set; } = string.Empty;
[JsonPropertyName("purchase_item_type")]
[Key("purchase_item_type")]
public int PurchaseItemType { get; set; }
[JsonPropertyName("purchase_item_id")]
[Key("purchase_item_id")]
public long PurchaseItemId { get; set; }
[JsonPropertyName("purchase_item_num")]
[Key("purchase_item_num")]
public int PurchaseItemNum { get; set; }
/// <summary>0 or 1 — client compares to int 0.</summary>
[JsonPropertyName("is_monthly_reset")]
[Key("is_monthly_reset")]
public int IsMonthlyReset { get; set; }
[JsonPropertyName("rest")]
[Key("rest")]
public int Rest { get; set; }
}
[MessagePackObject]
public class UserCardPackTicketDto
{
[JsonPropertyName("item_id")]
[Key("item_id")]
public int ItemId { get; set; }
[JsonPropertyName("number")]
[Key("number")]
public int Number { get; set; }
}

View File

@@ -0,0 +1,19 @@
using System.Text.Json.Serialization;
using MessagePack;
using SVSim.EmulatedEntrypoint.Models.Dtos;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ItemPurchase;
/// <summary>
/// /item_purchase/purchase response. <c>reward_list</c> uses the standard
/// <see cref="RewardListEntry"/> shape: post-state totals for currencies, grant counts for
/// items/cards. First entry is the debit-side post-state for the require_item; subsequent
/// entries are the grant(s) from RewardGrantService.
/// </summary>
[MessagePackObject]
public class ItemPurchasePurchaseResponse
{
[JsonPropertyName("reward_list")]
[Key("reward_list")]
public List<RewardListEntry> RewardList { get; set; } = new();
}

View File

@@ -0,0 +1,264 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.Database.Models;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Controllers;
public class ItemPurchaseControllerTests
{
private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json");
/// <summary>
/// Seeds two catalog entries:
/// #501: lifetime quota 1, costs 100 RedEther → 1 Item(1000)
/// #502: monthly quota 3, costs 5 Item(1001) → 1 Item(1000)
/// Plus the Item rows (1000, 1001) needed by RewardGrantService.
/// Caller seeds the viewer with starting currency/items.
/// </summary>
private static async Task SeedCatalog(SVSimTestFactory f)
{
using var scope = f.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
if (!await db.Items.AnyAsync(i => i.Id == 1000))
db.Items.Add(new ItemEntry { Id = 1000, Name = "Seer's Globe", Type = 3, ThumbnailPath = "thumbnail_orb" });
if (!await db.Items.AnyAsync(i => i.Id == 1001))
db.Items.Add(new ItemEntry { Id = 1001, Name = "Seer's Globe Shards", Type = 5, ThumbnailPath = "thumbnail_orb_piece" });
db.ItemPurchaseCatalog.AddRange(
new ItemPurchaseCatalogEntry
{
Id = 501, IsEnabled = true,
RequireItemType = 1, RequireItemId = 0, RequireItemNum = 100, // 100 RedEther
PurchaseItemType = 4, PurchaseItemId = 1000, PurchaseItemNum = 1, // → 1 Globe
PurchaseName = "Lifetime Globe", IsMonthlyReset = false, PurchaseLimit = 1,
},
new ItemPurchaseCatalogEntry
{
Id = 502, IsEnabled = true,
RequireItemType = 4, RequireItemId = 1001, RequireItemNum = 5, // 5 Shards
PurchaseItemType = 4, PurchaseItemId = 1000, PurchaseItemNum = 1, // → 1 Globe
PurchaseName = "Monthly Globe", IsMonthlyReset = true, PurchaseLimit = 3,
});
await db.SaveChangesAsync();
}
private static async Task SetViewerCurrency(SVSimTestFactory f, long viewerId, ulong redEther = 0)
{
using var scope = f.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
v.Currency.RedEther = redEther;
await db.SaveChangesAsync();
}
private static async Task SetViewerItem(SVSimTestFactory f, long viewerId, int itemId, int count)
{
using var scope = f.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var item = await db.Items.FindAsync(itemId);
var v = await db.Viewers.Include(x => x.Items).ThenInclude(i => i.Item).FirstAsync(x => x.Id == viewerId);
var owned = v.Items.FirstOrDefault(i => i.Item.Id == itemId);
if (owned is null)
v.Items.Add(new OwnedItemEntry { Item = item!, Count = count, Viewer = v });
else
owned.Count = count;
await db.SaveChangesAsync();
}
[Test]
public async Task Info_returns_catalog_and_full_ticket_list()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await factory.SeedGlobalsAsync(); // loads item catalog including Type==2 tickets
await SeedCatalog(factory);
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/item_purchase/info",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
using var doc = JsonDocument.Parse(body);
var info = doc.RootElement.GetProperty("item_purchase_info");
Assert.That(info.GetArrayLength(), Is.GreaterThanOrEqualTo(2), "should include seeded entries 501 and 502");
var entry501 = FindEntry(info, 501);
Assert.That(entry501.GetProperty("require_item_num").GetInt32(), Is.EqualTo(100));
Assert.That(entry501.GetProperty("is_monthly_reset").GetInt32(), Is.EqualTo(0));
Assert.That(entry501.GetProperty("rest").GetInt32(), Is.EqualTo(1));
var entry502 = FindEntry(info, 502);
Assert.That(entry502.GetProperty("is_monthly_reset").GetInt32(), Is.EqualTo(1));
Assert.That(entry502.GetProperty("rest").GetInt32(), Is.EqualTo(3));
// Ticket list should include every Type==2 item — seeded items.json has ~33 such rows.
var tickets = doc.RootElement.GetProperty("user_card_pack_ticket_list");
Assert.That(tickets.GetArrayLength(), Is.GreaterThan(10), "all Type==2 items should be listed");
// First-element shape check
Assert.That(tickets[0].GetProperty("item_id").GetInt32(), Is.GreaterThan(0));
}
[Test]
public async Task Purchase_with_red_ether_debits_and_grants_item()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedCatalog(factory);
await SetViewerCurrency(factory, viewerId, redEther: 5000);
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/item_purchase/purchase",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","purchase_id":501,"rest":1}"""));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
using var doc = JsonDocument.Parse(body);
var rewardList = doc.RootElement.GetProperty("reward_list");
Assert.That(rewardList.GetArrayLength(), Is.EqualTo(2)); // debit post-state + grant
// Debit: RedEther type=1, id=0, post-state total 4900
var debit = rewardList[0];
Assert.That(debit.GetProperty("reward_type").GetInt32(), Is.EqualTo(1));
Assert.That(debit.GetProperty("reward_id").GetInt64(), Is.EqualTo(0));
Assert.That(debit.GetProperty("reward_num").GetInt32(), Is.EqualTo(4900));
// Grant: Item type=4, id=1000, count=1 (viewer didn't have any before)
var grant = rewardList[1];
Assert.That(grant.GetProperty("reward_type").GetInt32(), Is.EqualTo(4));
Assert.That(grant.GetProperty("reward_id").GetInt64(), Is.EqualTo(1000));
Assert.That(grant.GetProperty("reward_num").GetInt32(), Is.EqualTo(1));
// Counter row should exist for lifetime quota
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var counter = await db.ViewerEventCounters
.FirstOrDefaultAsync(c => c.ViewerId == viewerId && c.EventKey == "item_purchase:501");
Assert.That(counter, Is.Not.Null);
Assert.That(counter!.Period, Is.EqualTo("all-time"));
Assert.That(counter.Count, Is.EqualTo(1));
}
[Test]
public async Task Purchase_with_item_currency_debits_and_grants_item()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedCatalog(factory);
await SetViewerItem(factory, viewerId, itemId: 1001, count: 12);
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/item_purchase/purchase",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","purchase_id":502,"rest":3}"""));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
using var doc = JsonDocument.Parse(body);
var rewardList = doc.RootElement.GetProperty("reward_list");
// Debit Item(1001) 12 → 7, grant Item(1000) 0 → 1
var debit = rewardList[0];
Assert.That(debit.GetProperty("reward_type").GetInt32(), Is.EqualTo(4));
Assert.That(debit.GetProperty("reward_id").GetInt64(), Is.EqualTo(1001));
Assert.That(debit.GetProperty("reward_num").GetInt32(), Is.EqualTo(7));
}
[Test]
public async Task Purchase_sold_out_returns_400()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedCatalog(factory);
await SetViewerCurrency(factory, viewerId, redEther: 500);
using var client = factory.CreateAuthenticatedClient(viewerId);
// First buy succeeds (entry 501 is lifetime quota 1)
var first = await client.PostAsync("/item_purchase/purchase",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","purchase_id":501,"rest":1}"""));
Assert.That(first.StatusCode, Is.EqualTo(HttpStatusCode.OK));
// Second buy rejected as sold_out — currency check is never reached
var second = await client.PostAsync("/item_purchase/purchase",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","purchase_id":501,"rest":0}"""));
Assert.That(second.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
}
[Test]
public async Task Purchase_with_insufficient_red_ether_returns_400_and_does_not_increment_counter()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedCatalog(factory);
await SetViewerCurrency(factory, viewerId, redEther: 50); // < 100 required
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/item_purchase/purchase",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","purchase_id":501,"rest":1}"""));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
// Counter must NOT have been incremented — quota stays at 1.
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var counter = await db.ViewerEventCounters
.FirstOrDefaultAsync(c => c.ViewerId == viewerId && c.EventKey == "item_purchase:501");
Assert.That(counter, Is.Null);
}
[Test]
public async Task Purchase_unknown_purchase_id_returns_400()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
// No SeedCatalog — purchase_id 501 doesn't exist
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/item_purchase/purchase",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","purchase_id":501,"rest":1}"""));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
}
[Test]
public async Task Monthly_quota_decrements_rest_on_repeat_buys_within_same_period()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedCatalog(factory);
await SetViewerItem(factory, viewerId, itemId: 1001, count: 20);
using var client = factory.CreateAuthenticatedClient(viewerId);
// entry 502 is monthly quota 3; buy twice
for (int i = 0; i < 2; i++)
{
var resp = await client.PostAsync("/item_purchase/purchase",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","purchase_id":502,"rest":3}"""));
Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
}
// /info should now report rest=1
var info = await client.PostAsync("/item_purchase/info",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""));
var body = await info.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
var entry502 = FindEntry(doc.RootElement.GetProperty("item_purchase_info"), 502);
Assert.That(entry502.GetProperty("rest").GetInt32(), Is.EqualTo(1));
}
private static JsonElement FindEntry(JsonElement array, int purchaseId)
{
foreach (var entry in array.EnumerateArray())
{
if (entry.GetProperty("purchase_id").GetInt32() == purchaseId)
return entry;
}
throw new InvalidOperationException($"entry with purchase_id={purchaseId} not found");
}
}

View File

@@ -0,0 +1,70 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Bootstrap.Importers;
using SVSim.Database;
using SVSim.Database.Models;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Importers;
public class ItemPurchaseImporterTests
{
private static string SeedDir => Path.Combine(AppContext.BaseDirectory, "Data", "seeds");
[Test]
public async Task Imports_catalog_from_seed_file()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
await new ItemPurchaseImporter().ImportAsync(db, SeedDir);
var entries = await db.ItemPurchaseCatalog.OrderBy(e => e.Id).ToListAsync();
Assert.That(entries.Count, Is.GreaterThan(0));
// Spot-check purchase_id 1: One Time Only Seer's Globe — 5000 RedEther → 1 Item(1000),
// lifetime quota of 1.
var one = entries.First(e => e.Id == 1);
Assert.That(one.RequireItemType, Is.EqualTo(1)); // RedEther
Assert.That(one.RequireItemNum, Is.EqualTo(5000));
Assert.That(one.PurchaseItemType, Is.EqualTo(4)); // Item
Assert.That(one.PurchaseItemId, Is.EqualTo(1000)); // Seer's Globe
Assert.That(one.IsMonthlyReset, Is.False);
Assert.That(one.PurchaseLimit, Is.EqualTo(1));
Assert.That(one.PurchaseName, Does.Contain("Seer's Globe"));
}
[Test]
public async Task Is_idempotent_on_rerun()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
await new ItemPurchaseImporter().ImportAsync(db, SeedDir);
int before = await db.ItemPurchaseCatalog.CountAsync();
await new ItemPurchaseImporter().ImportAsync(db, SeedDir);
int after = await db.ItemPurchaseCatalog.CountAsync();
Assert.That(after, Is.EqualTo(before));
}
[Test]
public async Task Leaves_existing_rows_untouched_when_missing_from_seed()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
const int legacyId = 999999;
db.ItemPurchaseCatalog.Add(new ItemPurchaseCatalogEntry { Id = legacyId, PurchaseName = "legacy", IsEnabled = true });
await db.SaveChangesAsync();
await new ItemPurchaseImporter().ImportAsync(db, SeedDir);
var legacy = await db.ItemPurchaseCatalog.FindAsync(legacyId);
Assert.That(legacy, Is.Not.Null);
Assert.That(legacy!.PurchaseName, Is.EqualTo("legacy"));
}
}

View File

@@ -210,6 +210,7 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
await new PaymentItemImporter().ImportAsync(ctx, seedDir); await new PaymentItemImporter().ImportAsync(ctx, seedDir);
await new ItemImporter().ImportAsync(ctx, seedDir); await new ItemImporter().ImportAsync(ctx, seedDir);
await new SleeveShopImporter().ImportAsync(ctx, seedDir); await new SleeveShopImporter().ImportAsync(ctx, seedDir);
await new ItemPurchaseImporter().ImportAsync(ctx, seedDir);
var puzzleImporter = new PuzzleImporter(); var puzzleImporter = new PuzzleImporter();
await puzzleImporter.ImportGroupsAsync(ctx, seedDir); await puzzleImporter.ImportGroupsAsync(ctx, seedDir);
await puzzleImporter.ImportPuzzlesAsync(ctx, seedDir); await puzzleImporter.ImportPuzzlesAsync(ctx, seedDir);