feat(spot-card-exchange): /spot_card_exchange/{top,exchange} + SpotPoints currency

Final shop family. Schema additions:
- ViewerCurrency.SpotPoints (ulong) — new currency column on Viewers.
- SpotCardExchangeEntry — catalog (distinct from the pre-existing
  SpotCardEntry, which is the /load/index rental-cost concept).
- ViewerSpotCardExchange — standalone composite-PK table tracking
  (viewer, card, exchanged_at, is_pre_release_snapshot). Standalone
  avoids cartesian-explode on viewer-graph reads.

RewardGrantService gains a SpotCardPoint=12 currency case mirroring
the RedEther/Crystal pattern. Doc comment refreshed; SpotCard=11 and
SpotCardOnlyLatestCardPack=13 remain unimplemented with explanatory
NotSupportedException — captures show emitters always use Card=5 with
the spot-card-specific id.

Controller:
- /top: emits exactly 9 clan buckets [{"1": [cards]}, ...] matching
  prod's arbitrary single-key shape. exchange_status per-card (0=
  available, 1=already-exchanged, 2=LimitOver after pre-release cap).
  pre_relase_info WIRE TYPO PRESERVED ("relase" not "release").
- /exchange: server-authoritative price (client-supplied
  exchange_point ignored); debits SpotPoints with post-state-total
  reward_list entry; grants card via RewardGrantService.ApplyAsync
  (cosmetic cascade included); persists ViewerSpotCardExchange row.
  Insufficient points / already-exchanged / pre-release-limit all
  return 400 without partial state.

LoadController now populates /load/index spot_point from
viewer.Currency.SpotPoints (was always 0).

PreReleaseLimit hardcoded to 2 matching capture; promote to GameConfig
when captures show variance.

504 tests pass (was 496; +8 spot-card-exchange tests).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-27 23:23:07 -04:00
parent a5999a3e9c
commit 7ef5f03eb3
20 changed files with 8298 additions and 6 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Bootstrap.Models.Seed;
using SVSim.Database;
using SVSim.Database.Models;
namespace SVSim.Bootstrap.Importers;
/// <summary>
/// Idempotent upsert of the spot card exchange catalog from <c>seeds/spot-card-exchange.json</c>.
/// Source is the wire <c>/spot_card_exchange/top</c> response, extracted via
/// <c>data_dumps/extract/extract-spot-card-exchange.py</c>. Rows missing from the seed are
/// LEFT INTACT.
/// </summary>
public class SpotCardExchangeImporter
{
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
{
string path = Path.Combine(seedDir, "spot-card-exchange.json");
var seed = SeedLoader.LoadList<SpotCardExchangeSeed>(path);
if (seed.Count == 0)
{
Console.WriteLine("[SpotCardExchangeImporter] No seed rows; skipping.");
return 0;
}
var existing = await context.SpotCardExchangeCatalog.ToDictionaryAsync(e => e.Id);
int created = 0, updated = 0;
foreach (var s in seed)
{
if (s.CardId == 0) continue;
var entry = existing.TryGetValue(s.CardId, out var ex)
? ex : new SpotCardExchangeEntry { Id = s.CardId };
entry.ClassId = s.ClassId;
entry.ExchangePoint = s.ExchangePoint;
entry.TsRotationId = s.TsRotationId;
entry.IsPreRelease = s.IsPreRelease;
entry.IsEnabled = true;
if (ex is null)
{
context.SpotCardExchangeCatalog.Add(entry);
existing[s.CardId] = entry;
created++;
}
else updated++;
}
await context.SaveChangesAsync();
Console.WriteLine($"[SpotCardExchangeImporter] +{created}/~{updated}");
return created + updated;
}
}

View File

@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
public sealed class SpotCardExchangeSeed
{
[JsonPropertyName("card_id")] public long CardId { get; set; }
[JsonPropertyName("class")] public int ClassId { get; set; }
[JsonPropertyName("exchange_point")] public int ExchangePoint { get; set; }
[JsonPropertyName("ts_rotation_id")] public long TsRotationId { get; set; }
[JsonPropertyName("is_pre_release")] public bool IsPreRelease { get; set; }
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,74 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddSpotCardExchange : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "Currency_SpotPoints",
table: "Viewers",
type: "numeric(20,0)",
nullable: false,
defaultValue: 0m);
migrationBuilder.CreateTable(
name: "SpotCardExchangeCatalog",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false),
CardId = table.Column<long>(type: "bigint", nullable: false),
ClassId = table.Column<int>(type: "integer", nullable: false),
ExchangePoint = table.Column<int>(type: "integer", nullable: false),
TsRotationId = table.Column<long>(type: "bigint", nullable: false),
IsPreRelease = table.Column<bool>(type: "boolean", 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_SpotCardExchangeCatalog", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ViewerSpotCardExchanges",
columns: table => new
{
ViewerId = table.Column<long>(type: "bigint", nullable: false),
CardId = table.Column<long>(type: "bigint", nullable: false),
IsPreRelease = table.Column<bool>(type: "boolean", nullable: false),
ExchangedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ViewerSpotCardExchanges", x => new { x.ViewerId, x.CardId });
});
migrationBuilder.CreateIndex(
name: "IX_ViewerSpotCardExchanges_ViewerId",
table: "ViewerSpotCardExchanges",
column: "ViewerId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SpotCardExchangeCatalog");
migrationBuilder.DropTable(
name: "ViewerSpotCardExchanges");
migrationBuilder.DropColumn(
name: "Currency_SpotPoints",
table: "Viewers");
}
}
}

View File

@@ -2206,6 +2206,40 @@ namespace SVSim.Database.Migrations
b.ToTable("SpotCards");
});
modelBuilder.Entity("SVSim.Database.Models.SpotCardExchangeEntry", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint");
b.Property<long>("CardId")
.HasColumnType("bigint");
b.Property<int>("ClassId")
.HasColumnType("integer");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DateUpdated")
.HasColumnType("timestamp with time zone");
b.Property<int>("ExchangePoint")
.HasColumnType("integer");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<bool>("IsPreRelease")
.HasColumnType("boolean");
b.Property<long>("TsRotationId")
.HasColumnType("bigint");
b.HasKey("Id");
b.ToTable("SpotCardExchangeCatalog");
});
modelBuilder.Entity("SVSim.Database.Models.UnlimitedRestrictionEntry", b =>
{
b.Property<long>("Id")
@@ -2473,6 +2507,27 @@ namespace SVSim.Database.Migrations
b.ToTable("ViewerPuzzleClears");
});
modelBuilder.Entity("SVSim.Database.Models.ViewerSpotCardExchange", b =>
{
b.Property<long>("ViewerId")
.HasColumnType("bigint");
b.Property<long>("CardId")
.HasColumnType("bigint");
b.Property<DateTime>("ExchangedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsPreRelease")
.HasColumnType("boolean");
b.HasKey("ViewerId", "CardId");
b.HasIndex("ViewerId");
b.ToTable("ViewerSpotCardExchanges");
});
modelBuilder.Entity("SleeveEntryViewer", b =>
{
b.Property<int>("SleevesId")
@@ -3390,6 +3445,9 @@ namespace SVSim.Database.Migrations
b1.Property<decimal>("Rupees")
.HasColumnType("numeric(20,0)");
b1.Property<decimal>("SpotPoints")
.HasColumnType("numeric(20,0)");
b1.Property<decimal>("SteamCrystals")
.HasColumnType("numeric(20,0)");

View File

@@ -0,0 +1,30 @@
using SVSim.Database.Common;
namespace SVSim.Database.Models;
/// <summary>
/// One catalog entry of the /spot_card_exchange/top shop — a card the viewer can buy with
/// spot points. PK = wire card_id. Distinct from <see cref="SpotCardEntry"/> (which is the
/// /load/index data.spot_cards rental-cost list — a different concept).
/// <para>
/// <see cref="TsRotationId"/> matches the card_set_id; cards cycle out of the exchange when
/// their set rotates. <see cref="IsPreRelease"/> distinguishes the pre-release-pool subset
/// gated by <c>pre_release_spot_card_exchange_limit</c>.
/// </para>
/// </summary>
public class SpotCardExchangeEntry : BaseEntity<long>
{
public long CardId { get => Id; set => Id = value; }
/// <summary>Wire <c>class</c> field — clan id (0=Neutral, 1=Forestcraft, ..., 8).</summary>
public int ClassId { get; set; }
public int ExchangePoint { get; set; }
/// <summary>Wire <c>ts_rotation_id</c> — card_set_id this card belongs to.</summary>
public long TsRotationId { get; set; }
public bool IsPreRelease { get; set; }
public bool IsEnabled { get; set; }
}

View File

@@ -14,4 +14,11 @@ public class ViewerCurrency
public ulong LifeTotalCrystals { get; set; }
public ulong RedEther { get; set; }
public ulong Rupees { get; set; }
/// <summary>
/// Spot card points — currency earned from battles/missions, spent at /spot_card_exchange/exchange.
/// Wire field <c>spot_point</c> in /load/index and /spot_card_exchange/top; reward_type 12
/// (<see cref="Enums.UserGoodsType.SpotCardPoint"/>) in reward_list entries.
/// </summary>
public ulong SpotPoints { get; set; }
}

View File

@@ -0,0 +1,16 @@
namespace SVSim.Database.Models;
/// <summary>
/// One row per (viewer, exchanged card). Composite PK (ViewerId, CardId). Standalone table
/// (not a Viewer owned collection) to avoid cartesian-explode on viewer-graph reads.
/// <see cref="IsPreRelease"/> snapshot at exchange time so the pre-release counter can be
/// computed without joining back to <see cref="SpotCardExchangeEntry"/> (and to survive
/// catalog edits that re-classify a card).
/// </summary>
public class ViewerSpotCardExchange
{
public long ViewerId { get; set; }
public long CardId { get; set; }
public bool IsPreRelease { get; set; }
public DateTime ExchangedAt { get; set; }
}

View File

@@ -76,6 +76,8 @@ public class SVSimDbContext : DbContext
public DbSet<LeaderSkinShopSeriesEntry> LeaderSkinShopSeries => Set<LeaderSkinShopSeriesEntry>();
public DbSet<LeaderSkinShopProductEntry> LeaderSkinShopProducts => Set<LeaderSkinShopProductEntry>();
public DbSet<ViewerLeaderSkinSetClaim> ViewerLeaderSkinSetClaims => Set<ViewerLeaderSkinSetClaim>();
public DbSet<SpotCardExchangeEntry> SpotCardExchangeCatalog => Set<SpotCardExchangeEntry>();
public DbSet<ViewerSpotCardExchange> ViewerSpotCardExchanges => Set<ViewerSpotCardExchange>();
public DbSet<MaintenanceCardEntry> MaintenanceCards => Set<MaintenanceCardEntry>();
public DbSet<FeatureMaintenanceEntry> FeatureMaintenances => Set<FeatureMaintenanceEntry>();
public DbSet<PreReleaseInfo> PreReleaseInfos => Set<PreReleaseInfo>();
@@ -209,6 +211,12 @@ public class SVSimDbContext : DbContext
b.HasIndex(c => c.ViewerId);
});
modelBuilder.Entity<ViewerSpotCardExchange>(b =>
{
b.HasKey(e => new { e.ViewerId, e.CardId });
b.HasIndex(e => e.ViewerId);
});
modelBuilder.Entity<CardCosmeticReward>(b =>
{
b.HasKey(r => new { r.CardId, r.Type, r.CosmeticId });

View File

@@ -20,8 +20,9 @@ public sealed record GrantedReward(int RewardType, long RewardId, int RewardNum)
///
/// <para>
/// <b>DO NOT reimplement reward dispatch in a controller or new helper.</b> This service handles
/// RedEther, Crystal, Item, Card (with <see cref="CardCosmeticReward"/> cascade), Sleeve, Emblem,
/// Degree, Rupy, Skin, MyPageBG — everything except SpotCard (TODO). Endpoint code that takes a
/// RedEther, Crystal, SpotCardPoint, Item, Card (with <see cref="CardCosmeticReward"/> cascade),
/// Sleeve, Emblem, Degree, Rupy, Skin, MyPageBG — everything except the dead-letter SpotCard /
/// SpotCardOnlyLatestCardPack slots (use Card=5 instead). Endpoint code that takes a
/// list of <c>(type, id, num)</c> tuples should iterate and call <see cref="ApplyAsync"/>
/// 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
@@ -87,6 +88,10 @@ public sealed class RewardGrantService
viewer.Currency.RedEther += (ulong)num;
return Single(type, detailId, checked((int)viewer.Currency.RedEther));
case UserGoodsType.SpotCardPoint:
viewer.Currency.SpotPoints += (ulong)num;
return Single(type, detailId, checked((int)viewer.Currency.SpotPoints));
case UserGoodsType.Item:
{
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId);
@@ -106,11 +111,11 @@ public sealed class RewardGrantService
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.
// Spot-card-typed grants don't appear in captures — emitters always use Card=5
// with the spot-card-specific id. These two enum slots remain unimplemented; if a
// capture ever shows one in a reward_list we'll know to wire them up here.
throw new NotSupportedException(
$"{type} rewards are not yet supported — see SpotCard TODO in RewardGrantService.");
$"{type} rewards are not yet supported — emitters use Card=5 instead.");
default:
throw new NotSupportedException($"UserGoodsType {type} not yet handled by RewardGrantService");

View File

@@ -181,6 +181,7 @@ public class LoadController : SVSimController
UserInfo = new UserInfo(deviceType, viewer),
UserCurrency = new UserCurrency(viewer),
UserItems = viewer.Items.Select(item => new UserItem(item)).ToList(),
SpotPoint = checked((int)viewer.Currency.SpotPoints),
UserRotationDecks = new UserFormatDeckInfo
{
UserDecks = viewer.Decks.Where(d => d.Format == Format.Rotation)

View File

@@ -0,0 +1,192 @@
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.SpotCardExchange;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.SpotCardExchange;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// /spot_card_exchange/* — trade spot points for individual cards from the rotating exchange
/// pool. Spot points are earned from battles/missions (not implemented here — earners live in
/// battle/mission finish reward emitters via <see cref="RewardGrantService"/> +
/// <see cref="UserGoodsType.SpotCardPoint"/>).
/// </summary>
[Route("spot_card_exchange")]
public class SpotCardExchangeController : SVSimController
{
/// <summary>
/// Pre-release exchange cap. Captures show "2" — global limit, not per-card. When
/// IsPreRelease is active on the catalog level we honour this; otherwise the cap is
/// effectively unbounded (UI never shows the warning).
/// </summary>
private const int PreReleaseLimit = 2;
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
private readonly TimeProvider _time;
public SpotCardExchangeController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time)
{
_db = db;
_rewards = rewards;
_time = time;
}
[HttpPost("top")]
public async Task<ActionResult<SpotCardExchangeTopResponse>> Top()
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var viewer = await _db.Viewers
.Where(v => v.Id == viewerId)
.Select(v => new { v.Currency.SpotPoints })
.FirstOrDefaultAsync();
if (viewer is null) return Unauthorized();
var catalog = await _db.SpotCardExchangeCatalog
.Where(c => c.IsEnabled)
.OrderBy(c => c.Id)
.ToListAsync();
var exchanges = await _db.ViewerSpotCardExchanges
.Where(e => e.ViewerId == viewerId)
.ToListAsync();
var exchangedIds = exchanges.Select(e => e.CardId).ToHashSet();
int preReleaseExchangedCount = exchanges.Count(e => e.IsPreRelease);
bool preReleaseActive = catalog.Any(c => c.IsPreRelease);
bool preReleaseLimitHit = preReleaseExchangedCount >= PreReleaseLimit;
// Build the 9-clan-bucket dict-of-arrays. Every clan slot is present even when empty;
// the inner dict always uses key "1" matching the captured prod shape.
var byClan = new List<Dictionary<string, List<SpotCardExchangeCardDto>>>(9);
for (int clan = 0; clan < 9; clan++)
{
byClan.Add(new Dictionary<string, List<SpotCardExchangeCardDto>>
{
["1"] = new List<SpotCardExchangeCardDto>(),
});
}
foreach (var c in catalog)
{
int clanIdx = Math.Clamp(c.ClassId, 0, 8);
byClan[clanIdx]["1"].Add(new SpotCardExchangeCardDto
{
CardId = c.Id,
ExchangeStatus = ComputeExchangeStatus(c, exchangedIds, preReleaseLimitHit),
ExchangePoint = c.ExchangePoint.ToString(),
Class = c.ClassId.ToString(),
IsPreRelease = c.IsPreRelease,
TsRotationId = c.TsRotationId.ToString(),
});
}
return new SpotCardExchangeTopResponse
{
SpotPoint = checked((int)viewer.SpotPoints),
ExchangeableCardList = byClan,
SoonCycleOutCardSetId = string.Empty, // No captured value to derive; spec allows ""
PreReleaseInfo = new PreReleaseInfoDto
{
IsPreRelease = preReleaseActive,
PreReleaseSpotCardExchangeCount = preReleaseExchangedCount,
PreReleaseSpotCardExchangeLimit = PreReleaseLimit,
},
};
}
[HttpPost("exchange")]
public async Task<ActionResult<SpotCardExchangeResponse>> Exchange(SpotCardExchangeRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var entry = await _db.SpotCardExchangeCatalog.FindAsync((long)request.CardId);
if (entry is null || !entry.IsEnabled)
return BadRequest(new { error = "unknown_card" });
// Already-exchanged guard — each catalog row is one card per viewer.
var existingExchange = await _db.ViewerSpotCardExchanges
.FirstOrDefaultAsync(e => e.ViewerId == viewerId && e.CardId == entry.Id);
if (existingExchange is not null)
return BadRequest(new { error = "already_exchanged" });
if (entry.IsPreRelease)
{
int prCount = await _db.ViewerSpotCardExchanges
.CountAsync(e => e.ViewerId == viewerId && e.IsPreRelease);
if (prCount >= PreReleaseLimit)
return BadRequest(new { error = "pre_release_limit_reached" });
}
var viewer = await LoadViewerGraphAsync(viewerId);
var rewardList = new List<RewardListEntry>();
// Debit spot points. Client-supplied exchange_point isn't authoritative — server uses
// catalog price. Mirroring the build_deck/sleeve convention: post-state currency entry
// first, then grants.
if (viewer.Currency.SpotPoints < (ulong)entry.ExchangePoint)
return BadRequest(new { error = "insufficient_spot_points" });
viewer.Currency.SpotPoints -= (ulong)entry.ExchangePoint;
rewardList.Add(new RewardListEntry
{
RewardType = (int)UserGoodsType.SpotCardPoint,
RewardId = 0,
RewardNum = checked((int)viewer.Currency.SpotPoints),
});
// Grant the card itself via the existing card dispatcher (handles cosmetic cascade).
var granted = await _rewards.ApplyAsync(viewer, UserGoodsType.Card, entry.Id, 1);
foreach (var g in granted)
{
rewardList.Add(new RewardListEntry
{
RewardType = g.RewardType,
RewardId = g.RewardId,
RewardNum = g.RewardNum,
});
}
_db.ViewerSpotCardExchanges.Add(new ViewerSpotCardExchange
{
ViewerId = viewerId,
CardId = entry.Id,
IsPreRelease = entry.IsPreRelease,
ExchangedAt = _time.GetUtcNow().UtcDateTime,
});
await _db.SaveChangesAsync();
return new SpotCardExchangeResponse { RewardList = rewardList };
}
/// <summary>
/// Maps to <see cref="Wizard.SpotCardExchangeInfo.ExchangeStatus"/>:
/// 0 = EnableExchange
/// 1 = AlreadyExchange (viewer has already exchanged this card)
/// 2 = LimitOver (pre-release card and viewer hit the global pre-release cap)
/// Insufficient-balance is NOT surfaced here — the client greys those out by comparing
/// <c>spot_point</c> to <c>exchange_point</c>.
/// </summary>
private static int ComputeExchangeStatus(SpotCardExchangeEntry c, HashSet<long> exchangedIds, bool preReleaseLimitHit)
{
if (exchangedIds.Contains(c.Id)) return 1;
if (c.IsPreRelease && preReleaseLimitHit) return 2;
return 0;
}
private Task<Viewer> LoadViewerGraphAsync(long viewerId) => _db.Viewers
.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)
.Include(v => v.Items).ThenInclude(i => i.Item)
.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.SpotCardExchange;
/// <summary>
/// /spot_card_exchange/exchange request — trade <see cref="ExchangePoint"/> spot points for
/// the card identified by <see cref="CardId"/>. The exchange_point field is the client's view
/// of the price (sanity-check it against the catalog server-side).
/// </summary>
[MessagePackObject]
public class SpotCardExchangeRequest : BaseRequest
{
[JsonPropertyName("card_id")]
[Key("card_id")]
public int CardId { get; set; }
[JsonPropertyName("exchange_point")]
[Key("exchange_point")]
public int ExchangePoint { get; set; }
}

View File

@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
using MessagePack;
using SVSim.EmulatedEntrypoint.Models.Dtos;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.SpotCardExchange;
/// <summary>
/// /spot_card_exchange/exchange response. <c>reward_list</c> entries follow the standard
/// shape: SpotCardPoint debit post-state first, then the card grant (with cosmetic cascade
/// if applicable).
/// </summary>
[MessagePackObject]
public class SpotCardExchangeResponse
{
[JsonPropertyName("reward_list")]
[Key("reward_list")]
public List<RewardListEntry> RewardList { get; set; } = new();
}

View File

@@ -0,0 +1,88 @@
using System.Text.Json.Serialization;
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.SpotCardExchange;
/// <summary>
/// /spot_card_exchange/top response.
/// <para>
/// <c>exchangeable_card_list</c> is an array of exactly 9 entries indexed by clan id 0..8.
/// Each entry is a dict keyed by an arbitrary stringified int (prod always emits "1") whose
/// value is the array of cards for that clan. The client iterates by clan index then dict-keys
/// (LitJson positional iteration).
/// </para>
/// <para>
/// <c>pre_relase_info</c> — WIRE TYPO PRESERVED ("relase" not "release"). Renaming this field
/// breaks the client's <c>jsonData["pre_relase_info"]</c> access.
/// </para>
/// </summary>
[MessagePackObject]
public class SpotCardExchangeTopResponse
{
[JsonPropertyName("spot_point")]
[Key("spot_point")]
public int SpotPoint { get; set; }
[JsonPropertyName("exchangeable_card_list")]
[Key("exchangeable_card_list")]
public List<Dictionary<string, List<SpotCardExchangeCardDto>>> ExchangeableCardList { get; set; } = new();
/// <summary>Card set id about to cycle out of spot-card eligibility — drives "last chance!" UI.
/// Empty string in the captured response. Stays string-typed because the client uses
/// <c>int.TryParse</c>.</summary>
[JsonPropertyName("soon_cycle_out_card_set_id")]
[Key("soon_cycle_out_card_set_id")]
public string SoonCycleOutCardSetId { get; set; } = string.Empty;
[JsonPropertyName("pre_relase_info")]
[Key("pre_relase_info")]
public PreReleaseInfoDto PreReleaseInfo { get; set; } = new();
}
[MessagePackObject]
public class SpotCardExchangeCardDto
{
[JsonPropertyName("card_id")]
[Key("card_id")]
public long CardId { get; set; }
/// <summary>SpotCardExchangeInfo.ExchangeStatus — 0=EnableExchange, 1=AlreadyExchange, 2=LimitOver.</summary>
[JsonPropertyName("exchange_status")]
[Key("exchange_status")]
public int ExchangeStatus { get; set; }
/// <summary>Stringified price — prod ships e.g. "3500", client reads via .ToInt().</summary>
[JsonPropertyName("exchange_point")]
[Key("exchange_point")]
public string ExchangePoint { get; set; } = "0";
/// <summary>Stringified clan id. Prod ships "0".."8".</summary>
[JsonPropertyName("class")]
[Key("class")]
public string Class { get; set; } = "0";
[JsonPropertyName("is_pre_release")]
[Key("is_pre_release")]
public bool IsPreRelease { get; set; }
/// <summary>Stringified card_set_id this card belongs to.</summary>
[JsonPropertyName("ts_rotation_id")]
[Key("ts_rotation_id")]
public string TsRotationId { get; set; } = "0";
}
[MessagePackObject]
public class PreReleaseInfoDto
{
[JsonPropertyName("is_pre_release")]
[Key("is_pre_release")]
public bool IsPreRelease { get; set; }
[JsonPropertyName("pre_release_spot_card_exchange_count")]
[Key("pre_release_spot_card_exchange_count")]
public int PreReleaseSpotCardExchangeCount { get; set; }
[JsonPropertyName("pre_release_spot_card_exchange_limit")]
[Key("pre_release_spot_card_exchange_limit")]
public int PreReleaseSpotCardExchangeLimit { get; set; }
}

View File

@@ -0,0 +1,216 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Controllers;
public class SpotCardExchangeControllerTests
{
private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json");
/// <summary>
/// Seeds 3 catalog rows: a regular class-0 card, a regular class-1 card, and a pre-release
/// card. Plus card-catalog rows so RewardGrantService can resolve the grant. Caller sets
/// viewer SpotPoints.
/// </summary>
private static async Task SeedCatalog(SVSimTestFactory f)
{
using var scope = f.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
if (!await db.Cards.AnyAsync(c => c.Id == 900100001L))
db.Cards.Add(new ShadowverseCardEntry { Id = 900100001L, Name = "TestSpotNeutral", Rarity = Rarity.Bronze });
if (!await db.Cards.AnyAsync(c => c.Id == 900100002L))
db.Cards.Add(new ShadowverseCardEntry { Id = 900100002L, Name = "TestSpotClan1", Rarity = Rarity.Bronze });
if (!await db.Cards.AnyAsync(c => c.Id == 900100099L))
db.Cards.Add(new ShadowverseCardEntry { Id = 900100099L, Name = "TestSpotPreRelease", Rarity = Rarity.Gold });
db.SpotCardExchangeCatalog.AddRange(
new SpotCardExchangeEntry { Id = 900100001L, ClassId = 0, ExchangePoint = 3500, TsRotationId = 10001, IsPreRelease = false, IsEnabled = true },
new SpotCardExchangeEntry { Id = 900100002L, ClassId = 1, ExchangePoint = 3500, TsRotationId = 10001, IsPreRelease = false, IsEnabled = true },
new SpotCardExchangeEntry { Id = 900100099L, ClassId = 0, ExchangePoint = 1000, TsRotationId = 10001, IsPreRelease = true, IsEnabled = true });
await db.SaveChangesAsync();
}
private static async Task SetSpotPoints(SVSimTestFactory f, long viewerId, ulong points)
{
using var scope = f.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
v.Currency.SpotPoints = points;
await db.SaveChangesAsync();
}
[Test]
public async Task Top_returns_9_clan_buckets_with_pre_relase_info_typo()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedCatalog(factory);
await SetSpotPoints(factory, viewerId, 5000);
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/spot_card_exchange/top",
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 root = doc.RootElement;
Assert.That(root.GetProperty("spot_point").GetInt32(), Is.EqualTo(5000));
var ecl = root.GetProperty("exchangeable_card_list");
Assert.That(ecl.GetArrayLength(), Is.EqualTo(9), "wire shape: array of exactly 9 clan buckets");
// Clan 0 bucket should have 2 cards (class-0 neutral + pre-release in our seed).
var clan0 = ecl[0].GetProperty("1");
Assert.That(clan0.GetArrayLength(), Is.EqualTo(2));
// Wire typo preserved
Assert.That(root.TryGetProperty("pre_relase_info", out var prInfo), Is.True);
Assert.That(root.TryGetProperty("pre_release_info", out _), Is.False, "the typo-free spelling must NOT be emitted");
Assert.That(prInfo.GetProperty("pre_release_spot_card_exchange_limit").GetInt32(), Is.EqualTo(2));
Assert.That(prInfo.GetProperty("is_pre_release").GetBoolean(), Is.True, "catalog has a pre-release card");
}
[Test]
public async Task Exchange_debits_spot_points_and_grants_card()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedCatalog(factory);
await SetSpotPoints(factory, viewerId, 5000);
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/spot_card_exchange/exchange",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","card_id":900100001,"exchange_point":3500}"""));
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)); // SpotCardPoint post-state + Card grant
// Debit: SpotCardPoint type=12, id=0, post-state 1500 (5000 - 3500)
var debit = rewardList[0];
Assert.That(debit.GetProperty("reward_type").GetInt32(), Is.EqualTo(12));
Assert.That(debit.GetProperty("reward_id").GetInt64(), Is.EqualTo(0));
Assert.That(debit.GetProperty("reward_num").GetInt32(), Is.EqualTo(1500));
// Grant: Card type=5, id=card id, count=1
var grant = rewardList[1];
Assert.That(grant.GetProperty("reward_type").GetInt32(), Is.EqualTo(5));
Assert.That(grant.GetProperty("reward_id").GetInt64(), Is.EqualTo(900100001L));
// ViewerSpotCardExchange + viewer.Cards persisted
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var record = await db.ViewerSpotCardExchanges.FirstOrDefaultAsync(e => e.ViewerId == viewerId && e.CardId == 900100001L);
Assert.That(record, Is.Not.Null);
var owned = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card)
.FirstAsync(v => v.Id == viewerId);
Assert.That(owned.Cards.Any(c => c.Card.Id == 900100001L), Is.True);
Assert.That(owned.Currency.SpotPoints, Is.EqualTo(1500UL));
}
[Test]
public async Task Exchange_with_insufficient_points_returns_400()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedCatalog(factory);
await SetSpotPoints(factory, viewerId, 100);
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/spot_card_exchange/exchange",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","card_id":900100001,"exchange_point":3500}"""));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
// No exchange row should have been created
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
Assert.That(await db.ViewerSpotCardExchanges.CountAsync(e => e.ViewerId == viewerId), Is.EqualTo(0));
}
[Test]
public async Task Exchange_already_exchanged_card_returns_400()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedCatalog(factory);
await SetSpotPoints(factory, viewerId, 7000);
using var client = factory.CreateAuthenticatedClient(viewerId);
var first = await client.PostAsync("/spot_card_exchange/exchange",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","card_id":900100001,"exchange_point":3500}"""));
Assert.That(first.StatusCode, Is.EqualTo(HttpStatusCode.OK));
var second = await client.PostAsync("/spot_card_exchange/exchange",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","card_id":900100001,"exchange_point":3500}"""));
Assert.That(second.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
}
[Test]
public async Task Pre_release_limit_blocks_third_exchange_and_top_reports_LimitOver_status()
{
// Seed 3 pre-release cards; viewer can exchange 2 then hits the limit on the 3rd.
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
for (int i = 0; i < 3; i++)
{
long cid = 800100001L + i;
if (!await db.Cards.AnyAsync(c => c.Id == cid))
db.Cards.Add(new ShadowverseCardEntry { Id = cid, Name = $"PR{i}", Rarity = Rarity.Bronze });
db.SpotCardExchangeCatalog.Add(new SpotCardExchangeEntry
{
Id = cid, ClassId = 0, ExchangePoint = 100, TsRotationId = 10099, IsPreRelease = true, IsEnabled = true,
});
}
await db.SaveChangesAsync();
}
await SetSpotPoints(factory, viewerId, 10000);
using var client = factory.CreateAuthenticatedClient(viewerId);
// Two successful pre-release exchanges
var r1 = await client.PostAsync("/spot_card_exchange/exchange",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","card_id":800100001,"exchange_point":100}"""));
Assert.That(r1.StatusCode, Is.EqualTo(HttpStatusCode.OK));
var r2 = await client.PostAsync("/spot_card_exchange/exchange",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","card_id":800100002,"exchange_point":100}"""));
Assert.That(r2.StatusCode, Is.EqualTo(HttpStatusCode.OK));
// Third one rejected by pre-release limit (limit==2)
var r3 = await client.PostAsync("/spot_card_exchange/exchange",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","card_id":800100003,"exchange_point":100}"""));
Assert.That(r3.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
// /top should report status=2 (LimitOver) for the remaining pre-release card
var top = await client.PostAsync("/spot_card_exchange/top",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""));
var topBody = await top.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(topBody);
var clan0 = doc.RootElement.GetProperty("exchangeable_card_list")[0].GetProperty("1");
int? statusFor800100003 = null;
foreach (var card in clan0.EnumerateArray())
{
if (card.GetProperty("card_id").GetInt64() == 800100003L)
statusFor800100003 = card.GetProperty("exchange_status").GetInt32();
}
Assert.That(statusFor800100003, Is.EqualTo(2), "unexchanged pre-release card after hitting limit should show LimitOver");
var prCount = doc.RootElement.GetProperty("pre_relase_info").GetProperty("pre_release_spot_card_exchange_count").GetInt32();
Assert.That(prCount, Is.EqualTo(2));
}
}

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 SpotCardExchangeImporterTests
{
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 SpotCardExchangeImporter().ImportAsync(db, SeedDir);
var entries = await db.SpotCardExchangeCatalog.ToListAsync();
Assert.That(entries.Count, Is.GreaterThan(0));
// Spot-check: card 113041010 (class 0, exchange_point 3500, ts_rotation_id 10013)
var c = entries.FirstOrDefault(e => e.Id == 113041010);
Assert.That(c, Is.Not.Null);
Assert.That(c!.ClassId, Is.EqualTo(0));
Assert.That(c.ExchangePoint, Is.EqualTo(3500));
Assert.That(c.TsRotationId, Is.EqualTo(10013));
Assert.That(c.IsPreRelease, Is.False);
}
[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 SpotCardExchangeImporter().ImportAsync(db, SeedDir);
int before = await db.SpotCardExchangeCatalog.CountAsync();
await new SpotCardExchangeImporter().ImportAsync(db, SeedDir);
int after = await db.SpotCardExchangeCatalog.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 long legacyId = 999_999_999L;
db.SpotCardExchangeCatalog.Add(new SpotCardExchangeEntry
{
Id = legacyId, ClassId = 9, ExchangePoint = 99999, TsRotationId = 1, IsEnabled = true,
});
await db.SaveChangesAsync();
await new SpotCardExchangeImporter().ImportAsync(db, SeedDir);
var legacy = await db.SpotCardExchangeCatalog.FindAsync(legacyId);
Assert.That(legacy, Is.Not.Null);
Assert.That(legacy!.ExchangePoint, Is.EqualTo(99999));
}
}

View File

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