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:
@@ -7,6 +7,7 @@ using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.Pack;
|
||||
using SVSim.Database.Repositories.PackDrawTables;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.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 })
|
||||
.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
|
||||
{
|
||||
PackConfigList = packs
|
||||
.Select(p => ToDto(p, openCounts, ownedItemsByItemId, gachaPointBalancesByPackId, freeClaimsByCampaignId))
|
||||
.Select(p => ToDto(p, openCounts, ownedItemsByItemId, gachaPointBalancesByPackId, freeClaimsByCampaignId, starterClassesByPackId))
|
||||
.ToList(),
|
||||
};
|
||||
}
|
||||
@@ -114,7 +125,8 @@ public class PackController : SVSimController
|
||||
IReadOnlyDictionary<int, ViewerPackOpenCount> openCounts,
|
||||
IReadOnlyDictionary<long, int> ownedItemsByItemId,
|
||||
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;
|
||||
|
||||
@@ -204,6 +216,7 @@ public class PackController : SVSimController
|
||||
OpenCountLimit = p.OpenCountLimit,
|
||||
IsHide = p.IsHide ? 1 : 0,
|
||||
PackCategory = (int)p.PackCategory,
|
||||
SelectedClassId = starterClassesByPackId.TryGetValue(p.Id, out var chosenClass) ? chosenClass : null,
|
||||
GachaPoint = gachaPointDto,
|
||||
IsPreRelease = p.IsPreRelease,
|
||||
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")]
|
||||
public ActionResult<Dictionary<string, Dictionary<string, object>>> GetLeaderSkinOwnedStatus(
|
||||
[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.
|
||||
// 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
|
||||
// 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;
|
||||
if (isRotationStarter)
|
||||
{
|
||||
if (request.ClassId is not int cid || cid < 1 || cid > 8)
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -118,4 +118,14 @@ public class PackConfigDto
|
||||
[JsonPropertyName("poster_type")]
|
||||
[Key("poster_type")]
|
||||
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; }
|
||||
}
|
||||
Reference in New Issue
Block a user