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");
|
||||
});
|
||||
|
||||
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("Cards");
|
||||
@@ -4838,6 +4866,8 @@ namespace SVSim.Database.Migrations
|
||||
|
||||
b.Navigation("PackOpenCounts");
|
||||
|
||||
b.Navigation("PackStarterClasses");
|
||||
|
||||
b.Navigation("SocialAccountConnections");
|
||||
});
|
||||
|
||||
|
||||
@@ -83,6 +83,8 @@ public class Viewer : BaseEntity<long>
|
||||
|
||||
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<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 });
|
||||
});
|
||||
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 =>
|
||||
{
|
||||
b.WithOwner().HasForeignKey("ViewerId");
|
||||
|
||||
Reference in New Issue
Block a user