feat(pack): /pack/set_rotation_starter_class + selected_class_id read-back
Implements Task 9 from docs/superpowers/plans/2026-06-13-decomp-only-followups.md
— the pre-purchase commit endpoint the client fires from StarterClassSelectDialog
before opening a RotationStarterCardPack. Without this the per-class draw path
from yesterday's work is unreachable: the dialog blocks on the AJAX call and
never advances to /pack/open.
Wire surface:
- POST /pack/set_rotation_starter_class { pack_id, class_id } → EmptyData.
Validates 1..8, rejects unknown packs (404), non-RS packs (400), and second
commits (400 — one-shot per pack per spec).
- PackConfigDto carries selected_class_id (nullable, omitted via global
WhenWritingNull policy when unset). Placement verified against decomp at
PackInfoTask.cs:86 — it's on the parent PackConfig, NOT the child gacha as
the original plan text had it.
- /pack/open cross-checks request.class_id against the persisted choice for
RS packs; rejects 400 on missing-commit or class-mismatch so a tampered
request can't swap pools after the choice is locked.
Storage:
- ViewerPackStarterClass owned entity; (ViewerId, PackId) unique index via the
pattern from project_owned_collection_unique_index memory.
- Migration PackStarterClass — composite PK + FK cascade verified against
fresh Postgres bootstrap.
Tests (9 new in PackControllerSetRotationStarterClassTests):
- Round-trip set → /pack/info shows selected_class_id.
- Field absent before any commit.
- Invalid class (0/9/-1/100) → 400.
- Already-chosen rejection.
- Non-RS pack / unknown pack rejection.
- /pack/open rejects no-prior-commit AND class-mismatch; succeeds when class
matches the persisted choice.
Full suite: 1552 passed (1543 + 9).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
5051
SVSim.Database/Migrations/20260627235051_PackStarterClass.Designer.cs
generated
Normal file
5051
SVSim.Database/Migrations/20260627235051_PackStarterClass.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
SVSim.Database/Migrations/20260627235051_PackStarterClass.cs
Normal file
49
SVSim.Database/Migrations/20260627235051_PackStarterClass.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace SVSim.Database.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class PackStarterClass : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ViewerPackStarterClass",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
ViewerId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
PackId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
ClassId = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ViewerPackStarterClass", x => new { x.ViewerId, x.Id });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ViewerPackStarterClass_Viewers_ViewerId",
|
||||||
|
column: x => x.ViewerId,
|
||||||
|
principalTable: "Viewers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ViewerPackStarterClass_ViewerId_PackId",
|
||||||
|
table: "ViewerPackStarterClass",
|
||||||
|
columns: new[] { "ViewerId", "PackId" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ViewerPackStarterClass");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4809,6 +4809,34 @@ namespace SVSim.Database.Migrations
|
|||||||
.HasForeignKey("ViewerId");
|
.HasForeignKey("ViewerId");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
b.OwnsMany("SVSim.Database.Models.ViewerPackStarterClass", "PackStarterClasses", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<long>("ViewerId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b1.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
|
||||||
|
|
||||||
|
b1.Property<int>("ClassId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b1.Property<int>("PackId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b1.HasKey("ViewerId", "Id");
|
||||||
|
|
||||||
|
b1.HasIndex("ViewerId", "PackId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b1.ToTable("ViewerPackStarterClass");
|
||||||
|
|
||||||
|
b1.WithOwner()
|
||||||
|
.HasForeignKey("ViewerId");
|
||||||
|
});
|
||||||
|
|
||||||
b.Navigation("BuildDeckPurchases");
|
b.Navigation("BuildDeckPurchases");
|
||||||
|
|
||||||
b.Navigation("Cards");
|
b.Navigation("Cards");
|
||||||
@@ -4838,6 +4866,8 @@ namespace SVSim.Database.Migrations
|
|||||||
|
|
||||||
b.Navigation("PackOpenCounts");
|
b.Navigation("PackOpenCounts");
|
||||||
|
|
||||||
|
b.Navigation("PackStarterClasses");
|
||||||
|
|
||||||
b.Navigation("SocialAccountConnections");
|
b.Navigation("SocialAccountConnections");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ public class Viewer : BaseEntity<long>
|
|||||||
|
|
||||||
public List<ViewerPackOpenCount> PackOpenCounts { get; set; } = new List<ViewerPackOpenCount>();
|
public List<ViewerPackOpenCount> PackOpenCounts { get; set; } = new List<ViewerPackOpenCount>();
|
||||||
|
|
||||||
|
public List<ViewerPackStarterClass> PackStarterClasses { get; set; } = new List<ViewerPackStarterClass>();
|
||||||
|
|
||||||
public List<ViewerFreePackClaim> FreePackClaims { get; set; } = new List<ViewerFreePackClaim>();
|
public List<ViewerFreePackClaim> FreePackClaims { get; set; } = new List<ViewerFreePackClaim>();
|
||||||
|
|
||||||
public List<MyPageBgRotationEntry> MyPageBgRotation { get; set; } = new List<MyPageBgRotationEntry>();
|
public List<MyPageBgRotationEntry> MyPageBgRotation { get; set; } = new List<MyPageBgRotationEntry>();
|
||||||
|
|||||||
17
SVSim.Database/Models/ViewerPackStarterClass.cs
Normal file
17
SVSim.Database/Models/ViewerPackStarterClass.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace SVSim.Database.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records a viewer's class choice for a <see cref="Enums.PackCategory.RotationStarterCardPack"/>.
|
||||||
|
/// One row per (Viewer, Pack) — the choice is one-shot per pack per
|
||||||
|
/// /pack/set_rotation_starter_class spec. Surfaces as <c>selected_class_id</c> on the parent
|
||||||
|
/// PackConfig in the next /pack/info response so the client's starter-pack dialog can
|
||||||
|
/// pre-select on revisit. <see cref="PackId"/> = parent_gacha_id; <see cref="ClassId"/> is 1..8.
|
||||||
|
/// </summary>
|
||||||
|
[Owned]
|
||||||
|
public class ViewerPackStarterClass
|
||||||
|
{
|
||||||
|
public int PackId { get; set; }
|
||||||
|
public int ClassId { get; set; }
|
||||||
|
}
|
||||||
@@ -184,6 +184,13 @@ public class SVSimDbContext : DbContext
|
|||||||
e.HasIndex(x => new { x.PackId, x.Slot, x.Tier });
|
e.HasIndex(x => new { x.PackId, x.Slot, x.Tier });
|
||||||
});
|
});
|
||||||
modelBuilder.Entity<Viewer>().OwnsMany(v => v.PackOpenCounts);
|
modelBuilder.Entity<Viewer>().OwnsMany(v => v.PackOpenCounts);
|
||||||
|
modelBuilder.Entity<Viewer>().OwnsMany(v => v.PackStarterClasses, b =>
|
||||||
|
{
|
||||||
|
// One choice per (viewer, pack) — /pack/set_rotation_starter_class is one-shot
|
||||||
|
// per pack. Mirrors the (ViewerId, PackId) unique-index pattern used by
|
||||||
|
// GachaPointBalances above (project_owned_collection_unique_index memory).
|
||||||
|
b.HasIndex("ViewerId", nameof(ViewerPackStarterClass.PackId)).IsUnique();
|
||||||
|
});
|
||||||
modelBuilder.Entity<Viewer>().OwnsMany(v => v.FreePackClaims, b =>
|
modelBuilder.Entity<Viewer>().OwnsMany(v => v.FreePackClaims, b =>
|
||||||
{
|
{
|
||||||
b.WithOwner().HasForeignKey("ViewerId");
|
b.WithOwner().HasForeignKey("ViewerId");
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using SVSim.Database.Models;
|
|||||||
using SVSim.Database.Repositories.Pack;
|
using SVSim.Database.Repositories.Pack;
|
||||||
using SVSim.Database.Repositories.PackDrawTables;
|
using SVSim.Database.Repositories.PackDrawTables;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||||
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Pack;
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Pack;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Pack;
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Pack;
|
||||||
@@ -101,10 +102,20 @@ public class PackController : SVSimController
|
|||||||
.Select(c => new { c.FreeGachaCampaignId, c.LastClaimedAt, c.ClaimCount })
|
.Select(c => new { c.FreeGachaCampaignId, c.LastClaimedAt, c.ClaimCount })
|
||||||
.ToDictionaryAsync(x => x.FreeGachaCampaignId, x => (x.LastClaimedAt, x.ClaimCount));
|
.ToDictionaryAsync(x => x.FreeGachaCampaignId, x => (x.LastClaimedAt, x.ClaimCount));
|
||||||
|
|
||||||
|
// Per-viewer RotationStarter class choices keyed by pack id — populates
|
||||||
|
// selected_class_id on the parent PackConfigDto so the client's
|
||||||
|
// StarterClassSelectDialog can pre-select on revisit (PackInfoTask.cs:86 reads via
|
||||||
|
// TryGetValue, so absent-key for non-RS packs is intentional/safe).
|
||||||
|
var starterClassesByPackId = await _db.Viewers
|
||||||
|
.Where(v => v.Id == viewerId)
|
||||||
|
.SelectMany(v => v.PackStarterClasses)
|
||||||
|
.Select(s => new { s.PackId, s.ClassId })
|
||||||
|
.ToDictionaryAsync(x => x.PackId, x => x.ClassId);
|
||||||
|
|
||||||
return new PackInfoResponse
|
return new PackInfoResponse
|
||||||
{
|
{
|
||||||
PackConfigList = packs
|
PackConfigList = packs
|
||||||
.Select(p => ToDto(p, openCounts, ownedItemsByItemId, gachaPointBalancesByPackId, freeClaimsByCampaignId))
|
.Select(p => ToDto(p, openCounts, ownedItemsByItemId, gachaPointBalancesByPackId, freeClaimsByCampaignId, starterClassesByPackId))
|
||||||
.ToList(),
|
.ToList(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -114,7 +125,8 @@ public class PackController : SVSimController
|
|||||||
IReadOnlyDictionary<int, ViewerPackOpenCount> openCounts,
|
IReadOnlyDictionary<int, ViewerPackOpenCount> openCounts,
|
||||||
IReadOnlyDictionary<long, int> ownedItemsByItemId,
|
IReadOnlyDictionary<long, int> ownedItemsByItemId,
|
||||||
IReadOnlyDictionary<int, int> gachaPointBalancesByPackId,
|
IReadOnlyDictionary<int, int> gachaPointBalancesByPackId,
|
||||||
IReadOnlyDictionary<int, (DateTime LastClaimedAt, int ClaimCount)> freeClaimsByCampaignId)
|
IReadOnlyDictionary<int, (DateTime LastClaimedAt, int ClaimCount)> freeClaimsByCampaignId,
|
||||||
|
IReadOnlyDictionary<int, int> starterClassesByPackId)
|
||||||
{
|
{
|
||||||
int openCount = openCounts.TryGetValue(p.Id, out var oc) ? oc.OpenCount : 0;
|
int openCount = openCounts.TryGetValue(p.Id, out var oc) ? oc.OpenCount : 0;
|
||||||
|
|
||||||
@@ -204,6 +216,7 @@ public class PackController : SVSimController
|
|||||||
OpenCountLimit = p.OpenCountLimit,
|
OpenCountLimit = p.OpenCountLimit,
|
||||||
IsHide = p.IsHide ? 1 : 0,
|
IsHide = p.IsHide ? 1 : 0,
|
||||||
PackCategory = (int)p.PackCategory,
|
PackCategory = (int)p.PackCategory,
|
||||||
|
SelectedClassId = starterClassesByPackId.TryGetValue(p.Id, out var chosenClass) ? chosenClass : null,
|
||||||
GachaPoint = gachaPointDto,
|
GachaPoint = gachaPointDto,
|
||||||
IsPreRelease = p.IsPreRelease,
|
IsPreRelease = p.IsPreRelease,
|
||||||
ExistsPurchaseReward = false,
|
ExistsPurchaseReward = false,
|
||||||
@@ -259,6 +272,43 @@ public class PackController : SVSimController
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// /pack/set_rotation_starter_class — lock in the class for a RotationStarterCardPack.
|
||||||
|
/// One-shot per (viewer, pack); rejects the second attempt with 400. The client opens
|
||||||
|
/// the pack via /pack/open afterwards (see Wizard/StarterClassSelectDialog.cs which
|
||||||
|
/// chains this call into StarterPurchaseConfirmationDialog on 200).
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("set_rotation_starter_class")]
|
||||||
|
public async Task<ActionResult<EmptyResponse>> SetRotationStarterClass(
|
||||||
|
PackSetRotationStarterClassRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||||
|
|
||||||
|
if (request.ClassId is < 1 or > 8)
|
||||||
|
return BadRequest(new { error = "invalid_class" });
|
||||||
|
|
||||||
|
var pack = await _packs.GetPack(request.PackId);
|
||||||
|
if (pack is null) return NotFound(new { error = "unknown_pack" });
|
||||||
|
if (pack.PackCategory != PackCategory.RotationStarterCardPack)
|
||||||
|
return BadRequest(new { error = "not_a_rotation_starter_pack" });
|
||||||
|
|
||||||
|
var viewer = await _db.Viewers
|
||||||
|
.Include(v => v.PackStarterClasses)
|
||||||
|
.FirstOrDefaultAsync(v => v.Id == viewerId, ct);
|
||||||
|
if (viewer is null) return Unauthorized();
|
||||||
|
|
||||||
|
if (viewer.PackStarterClasses.Any(s => s.PackId == request.PackId))
|
||||||
|
return BadRequest(new { error = "class_already_chosen" });
|
||||||
|
|
||||||
|
viewer.PackStarterClasses.Add(new ViewerPackStarterClass
|
||||||
|
{
|
||||||
|
PackId = request.PackId,
|
||||||
|
ClassId = request.ClassId,
|
||||||
|
});
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return new EmptyResponse();
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("get_leader_skin_owned_status")]
|
[HttpPost("get_leader_skin_owned_status")]
|
||||||
public ActionResult<Dictionary<string, Dictionary<string, object>>> GetLeaderSkinOwnedStatus(
|
public ActionResult<Dictionary<string, Dictionary<string, object>>> GetLeaderSkinOwnedStatus(
|
||||||
[FromBody] PackGetLeaderSkinOwnedStatusRequest _)
|
[FromBody] PackGetLeaderSkinOwnedStatusRequest _)
|
||||||
@@ -299,12 +349,25 @@ public class PackController : SVSimController
|
|||||||
// Rotation-starter packs require a class_id (1..8) to select the per-class card pool.
|
// Rotation-starter packs require a class_id (1..8) to select the per-class card pool.
|
||||||
// Reject if missing/out-of-range; reject class_id on non-RS packs so it can't sneak
|
// Reject if missing/out-of-range; reject class_id on non-RS packs so it can't sneak
|
||||||
// through and pick up nothing (a class-filtered draw against a non-RS pack would
|
// through and pick up nothing (a class-filtered draw against a non-RS pack would
|
||||||
// return an empty pool and 500).
|
// return an empty pool and 500). For RS packs the client is expected to have already
|
||||||
|
// committed the choice via /pack/set_rotation_starter_class — cross-check that the
|
||||||
|
// request class matches the persisted one so a tampered request can't swap pools
|
||||||
|
// mid-flight.
|
||||||
bool isRotationStarter = pack.PackCategory == PackCategory.RotationStarterCardPack;
|
bool isRotationStarter = pack.PackCategory == PackCategory.RotationStarterCardPack;
|
||||||
if (isRotationStarter)
|
if (isRotationStarter)
|
||||||
{
|
{
|
||||||
if (request.ClassId is not int cid || cid < 1 || cid > 8)
|
if (request.ClassId is not int cid || cid < 1 || cid > 8)
|
||||||
return BadRequest(new { error = "class_id_required_for_rotation_starter" });
|
return BadRequest(new { error = "class_id_required_for_rotation_starter" });
|
||||||
|
var persisted = await _db.Viewers
|
||||||
|
.Where(v => v.Id == viewerId)
|
||||||
|
.SelectMany(v => v.PackStarterClasses)
|
||||||
|
.Where(s => s.PackId == request.ParentGachaId)
|
||||||
|
.Select(s => (int?)s.ClassId)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (persisted is null)
|
||||||
|
return BadRequest(new { error = "rotation_starter_class_not_chosen" });
|
||||||
|
if (persisted != cid)
|
||||||
|
return BadRequest(new { error = "rotation_starter_class_mismatch" });
|
||||||
}
|
}
|
||||||
else if (request.ClassId.HasValue)
|
else if (request.ClassId.HasValue)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -118,4 +118,14 @@ public class PackConfigDto
|
|||||||
[JsonPropertyName("poster_type")]
|
[JsonPropertyName("poster_type")]
|
||||||
[Key("poster_type")]
|
[Key("poster_type")]
|
||||||
public int PosterType { get; set; }
|
public int PosterType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The viewer's already-chosen class for a RotationStarterCardPack (1..8). Null when the
|
||||||
|
/// viewer has not yet locked a choice via /pack/set_rotation_starter_class. Client at
|
||||||
|
/// PackInfoTask.cs:86 reads via <c>TryGetValue</c>, so a missing key is safe (don't emit
|
||||||
|
/// when null — global WhenWritingNull is sufficient here).
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("selected_class_id")]
|
||||||
|
[Key("selected_class_id")]
|
||||||
|
public int? SelectedClassId { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using MessagePack;
|
||||||
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||||
|
|
||||||
|
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Pack;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inbound /pack/set_rotation_starter_class body. Locks in the class choice for a
|
||||||
|
/// RotationStarterCardPack before the user can open it. One-shot per (viewer, pack)
|
||||||
|
/// per the spec; subsequent attempts return 400.
|
||||||
|
/// See <c>Wizard/PackSetRotationStarterClassTask.cs</c>.
|
||||||
|
/// </summary>
|
||||||
|
[MessagePackObject]
|
||||||
|
public class PackSetRotationStarterClassRequest : BaseRequest
|
||||||
|
{
|
||||||
|
[JsonPropertyName("pack_id")]
|
||||||
|
[Key("pack_id")]
|
||||||
|
public int PackId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("class_id")]
|
||||||
|
[Key("class_id")]
|
||||||
|
public int ClassId { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
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 PackControllerSetRotationStarterClassTests
|
||||||
|
{
|
||||||
|
private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds an active RotationStarterCardPack (id 93025) with a minimal class-1-only draw table
|
||||||
|
/// sufficient to satisfy /pack/open after a class is locked in. The class-axis sampling in
|
||||||
|
/// PackOpenService is covered by PackOpenServiceTests; this helper only needs a working table.
|
||||||
|
/// Returns the pack id.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<int> SeedRotationStarterPack(SVSimTestFactory f, long viewerId, int packId = 93025, ulong rupees = 5000)
|
||||||
|
{
|
||||||
|
using var scope = f.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
var set = await db.CardSets.Include(s => s.Cards).FirstAsync(s => s.Cards.Count > 0);
|
||||||
|
int baseId = set.Id;
|
||||||
|
long cardId = set.Cards.First().Id;
|
||||||
|
|
||||||
|
db.Packs.Add(new PackConfigEntry
|
||||||
|
{
|
||||||
|
Id = packId,
|
||||||
|
BasePackId = baseId,
|
||||||
|
PackCategory = PackCategory.RotationStarterCardPack,
|
||||||
|
CommenceDate = DateTime.UtcNow.AddDays(-1),
|
||||||
|
CompleteDate = DateTime.UtcNow.AddDays(30),
|
||||||
|
GachaType = 1,
|
||||||
|
GachaDetail = "rs-test",
|
||||||
|
SleeveId = 3000011,
|
||||||
|
ChildGachas = {
|
||||||
|
new PackChildGachaEntry { GachaId = packId * 10 + 1, TypeDetail = CardPackType.RupyMulti, Cost = 100, CardCount = 8 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
db.PackDrawConfigs.Add(new PackDrawConfigEntry { Id = packId, AnimationRatePct = 0 });
|
||||||
|
// Both slots: bronze 100% — single tier keeps the test deterministic.
|
||||||
|
db.PackDrawSlotRates.Add(new PackDrawSlotRateEntry { PackId = packId, Slot = DrawSlot.General, Tier = DrawTier.Bronze, RatePct = 100.0 });
|
||||||
|
db.PackDrawSlotRates.Add(new PackDrawSlotRateEntry { PackId = packId, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, RatePct = 100.0 });
|
||||||
|
// Class 3 pool only — verifies the class filter actually narrows. Open requests for
|
||||||
|
// any other class would fall through to FallbackAcrossTiers and 500 if the test ever
|
||||||
|
// expected them to succeed.
|
||||||
|
db.PackDrawCardWeights.Add(new PackDrawCardWeightEntry { PackId = packId, Slot = DrawSlot.General, Tier = DrawTier.Bronze, ClassId = 3, CardId = cardId, RatePct = 100 });
|
||||||
|
db.PackDrawCardWeights.Add(new PackDrawCardWeightEntry { PackId = packId, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, ClassId = 3, CardId = cardId, RatePct = 100 });
|
||||||
|
|
||||||
|
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
|
||||||
|
v.Currency.Rupees = rupees;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return packId;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task SetRotationStarterClass_persists_and_appears_in_pack_info()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
int packId = await SeedRotationStarterPack(factory, viewerId);
|
||||||
|
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
var setJson = $$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","pack_id":{{packId}},"class_id":3}""";
|
||||||
|
var setResp = await client.PostAsync("/pack/set_rotation_starter_class", JsonBody(setJson));
|
||||||
|
Assert.That(setResp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||||
|
|
||||||
|
var infoResp = await client.PostAsync("/pack/info",
|
||||||
|
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""));
|
||||||
|
Assert.That(infoResp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||||
|
|
||||||
|
var body = await infoResp.Content.ReadAsStringAsync();
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
var packs = doc.RootElement.GetProperty("pack_config_list");
|
||||||
|
JsonElement? pack = null;
|
||||||
|
foreach (var p in packs.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (p.GetProperty("parent_gacha_id").GetInt32() == packId) { pack = p; break; }
|
||||||
|
}
|
||||||
|
Assert.That(pack, Is.Not.Null, $"pack {packId} should be present in /pack/info");
|
||||||
|
Assert.That(pack!.Value.TryGetProperty("selected_class_id", out var sel), Is.True,
|
||||||
|
"selected_class_id must be present on the parent PackConfig after the choice");
|
||||||
|
Assert.That(sel.GetInt32(), Is.EqualTo(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task SetRotationStarterClass_omits_selected_class_id_before_choice()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
int packId = await SeedRotationStarterPack(factory, viewerId);
|
||||||
|
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
var infoResp = await client.PostAsync("/pack/info",
|
||||||
|
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""));
|
||||||
|
var body = await infoResp.Content.ReadAsStringAsync();
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
var pack = doc.RootElement.GetProperty("pack_config_list")
|
||||||
|
.EnumerateArray().First(p => p.GetProperty("parent_gacha_id").GetInt32() == packId);
|
||||||
|
// Global WhenWritingNull policy applies — key absent when no choice exists.
|
||||||
|
Assert.That(pack.TryGetProperty("selected_class_id", out _), Is.False,
|
||||||
|
"selected_class_id key must be absent before the viewer has chosen");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task SetRotationStarterClass_rejects_invalid_class()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
int packId = await SeedRotationStarterPack(factory, viewerId);
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
|
||||||
|
foreach (int bad in new[] { 0, 9, -1, 100 })
|
||||||
|
{
|
||||||
|
var json = $$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","pack_id":{{packId}},"class_id":{{bad}}}""";
|
||||||
|
var resp = await client.PostAsync("/pack/set_rotation_starter_class", JsonBody(json));
|
||||||
|
Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), $"class_id={bad} should be rejected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task SetRotationStarterClass_rejects_already_chosen()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
int packId = await SeedRotationStarterPack(factory, viewerId);
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
|
||||||
|
var json = $$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","pack_id":{{packId}},"class_id":3}""";
|
||||||
|
var first = await client.PostAsync("/pack/set_rotation_starter_class", JsonBody(json));
|
||||||
|
Assert.That(first.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||||
|
|
||||||
|
var json2 = $$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","pack_id":{{packId}},"class_id":5}""";
|
||||||
|
var second = await client.PostAsync("/pack/set_rotation_starter_class", JsonBody(json2));
|
||||||
|
Assert.That(second.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest),
|
||||||
|
"second commit should be rejected — choice is one-shot per pack");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task SetRotationStarterClass_rejects_non_rotation_starter_pack()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
// Seed a standard expansion pack (Category.None) — class lock doesn't apply.
|
||||||
|
using (var scope = factory.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
int baseId = await db.CardSets.Where(s => s.Cards.Count > 0).Select(s => s.Id).FirstAsync();
|
||||||
|
db.Packs.Add(new PackConfigEntry
|
||||||
|
{
|
||||||
|
Id = 10001, BasePackId = baseId, PackCategory = PackCategory.None,
|
||||||
|
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
|
||||||
|
GachaType = 1, GachaDetail = "ord", SleeveId = 3000011,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","pack_id":10001,"class_id":3}""";
|
||||||
|
var resp = await client.PostAsync("/pack/set_rotation_starter_class", JsonBody(json));
|
||||||
|
Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task SetRotationStarterClass_rejects_unknown_pack()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","pack_id":99999999,"class_id":3}""";
|
||||||
|
var resp = await client.PostAsync("/pack/set_rotation_starter_class", JsonBody(json));
|
||||||
|
Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Open_rs_pack_without_prior_class_commit_is_rejected()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
int packId = await SeedRotationStarterPack(factory, viewerId);
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
|
||||||
|
// Skip /set_rotation_starter_class — go straight to /pack/open with class_id=3.
|
||||||
|
var json = $$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":{{packId}},"gacha_id":{{packId * 10 + 1}},"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"class_id":3}""";
|
||||||
|
var resp = await client.PostAsync("/pack/open", JsonBody(json));
|
||||||
|
Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest),
|
||||||
|
"/pack/open on an RS pack must require a prior /set_rotation_starter_class commit");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Open_rs_pack_rejects_class_id_mismatch()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
int packId = await SeedRotationStarterPack(factory, viewerId);
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
|
||||||
|
// Commit class 3, then attempt to open as class 5.
|
||||||
|
var setJson = $$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","pack_id":{{packId}},"class_id":3}""";
|
||||||
|
var setResp = await client.PostAsync("/pack/set_rotation_starter_class", JsonBody(setJson));
|
||||||
|
Assert.That(setResp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||||
|
|
||||||
|
var openJson = $$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":{{packId}},"gacha_id":{{packId * 10 + 1}},"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"class_id":5}""";
|
||||||
|
var openResp = await client.PostAsync("/pack/open", JsonBody(openJson));
|
||||||
|
Assert.That(openResp.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest),
|
||||||
|
"/pack/open class_id must match the persisted choice");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Open_rs_pack_with_matching_class_id_succeeds()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
int packId = await SeedRotationStarterPack(factory, viewerId);
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
|
||||||
|
var setJson = $$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","pack_id":{{packId}},"class_id":3}""";
|
||||||
|
var setResp = await client.PostAsync("/pack/set_rotation_starter_class", JsonBody(setJson));
|
||||||
|
Assert.That(setResp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||||
|
|
||||||
|
var openJson = $$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":{{packId}},"gacha_id":{{packId * 10 + 1}},"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"class_id":3}""";
|
||||||
|
var openResp = await client.PostAsync("/pack/open", JsonBody(openJson));
|
||||||
|
Assert.That(openResp.StatusCode, Is.EqualTo(HttpStatusCode.OK),
|
||||||
|
$"/pack/open with matching class should succeed; body={await openResp.Content.ReadAsStringAsync()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user