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:
3733
SVSim.Bootstrap/Data/seeds/spot-card-exchange.json
Normal file
3733
SVSim.Bootstrap/Data/seeds/spot-card-exchange.json
Normal file
File diff suppressed because it is too large
Load Diff
55
SVSim.Bootstrap/Importers/SpotCardExchangeImporter.cs
Normal file
55
SVSim.Bootstrap/Importers/SpotCardExchangeImporter.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
12
SVSim.Bootstrap/Models/Seed/SpotCardExchangeSeed.cs
Normal file
12
SVSim.Bootstrap/Models/Seed/SpotCardExchangeSeed.cs
Normal 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; }
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
3685
SVSim.Database/Migrations/20260528030221_AddSpotCardExchange.Designer.cs
generated
Normal file
3685
SVSim.Database/Migrations/20260528030221_AddSpotCardExchange.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)");
|
||||
|
||||
|
||||
30
SVSim.Database/Models/SpotCardExchangeEntry.cs
Normal file
30
SVSim.Database/Models/SpotCardExchangeEntry.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
16
SVSim.Database/Models/ViewerSpotCardExchange.cs
Normal file
16
SVSim.Database/Models/ViewerSpotCardExchange.cs
Normal 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; }
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
216
SVSim.UnitTests/Controllers/SpotCardExchangeControllerTests.cs
Normal file
216
SVSim.UnitTests/Controllers/SpotCardExchangeControllerTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
70
SVSim.UnitTests/Importers/SpotCardExchangeImporterTests.cs
Normal file
70
SVSim.UnitTests/Importers/SpotCardExchangeImporterTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user