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>
132 lines
5.0 KiB
C#
132 lines
5.0 KiB
C#
using MessagePack;
|
|
using System.Text.Json.Serialization;
|
|
|
|
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
|
|
|
|
[MessagePackObject]
|
|
public class PackConfigDto
|
|
{
|
|
[JsonPropertyName("parent_gacha_id")]
|
|
[Key("parent_gacha_id")]
|
|
public int ParentGachaId { get; set; }
|
|
|
|
[JsonPropertyName("base_pack_id")]
|
|
[Key("base_pack_id")]
|
|
public int BasePackId { get; set; }
|
|
|
|
[JsonPropertyName("override_draw_effect_pack_id")]
|
|
[Key("override_draw_effect_pack_id")]
|
|
public int OverrideDrawEffectPackId { get; set; }
|
|
|
|
[JsonPropertyName("override_ui_effect_pack_id")]
|
|
[Key("override_ui_effect_pack_id")]
|
|
public int OverrideUiEffectPackId { get; set; }
|
|
|
|
[JsonPropertyName("gacha_type")]
|
|
[Key("gacha_type")]
|
|
public int GachaType { get; set; }
|
|
|
|
[JsonPropertyName("sleeve_id")]
|
|
[Key("sleeve_id")]
|
|
public int SleeveId { get; set; } = 3000011;
|
|
|
|
[JsonPropertyName("special_sleeve_id")]
|
|
[Key("special_sleeve_id")]
|
|
public int SpecialSleeveId { get; set; }
|
|
|
|
[JsonPropertyName("commence_date")]
|
|
[Key("commence_date")]
|
|
public string CommenceDate { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("complete_date")]
|
|
[Key("complete_date")]
|
|
public string CompleteDate { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("cardpack_banner_list")]
|
|
[Key("cardpack_banner_list")]
|
|
public List<PackBannerDto> CardpackBannerList { get; set; } = new();
|
|
|
|
[JsonPropertyName("gacha_detail")]
|
|
[Key("gacha_detail")]
|
|
public string GachaDetail { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("child_gacha_info")]
|
|
[Key("child_gacha_info")]
|
|
public List<PackChildGachaDto> ChildGachaInfo { get; set; } = new();
|
|
|
|
[JsonPropertyName("open_count")]
|
|
[Key("open_count")]
|
|
public int OpenCount { get; set; }
|
|
|
|
[JsonPropertyName("open_count_limit")]
|
|
[Key("open_count_limit")]
|
|
public int OpenCountLimit { get; set; }
|
|
|
|
[JsonPropertyName("is_hide")]
|
|
[Key("is_hide")]
|
|
public int IsHide { get; set; }
|
|
|
|
[JsonPropertyName("pack_category")]
|
|
[Key("pack_category")]
|
|
public int PackCategory { get; set; }
|
|
|
|
/// <summary>
|
|
/// Null when the pack has no gacha-point participation. The key MUST be present on the wire
|
|
/// (explicit null) — client at PackInfoTask.cs:126 does <c>if (jsonData2["gacha_point"] != null)</c>,
|
|
/// a direct LitJson key access that throws KeyNotFoundException when the key is absent
|
|
/// (only protects against null *value*, not missing *key*). Override the global
|
|
/// WhenWritingNull per [[project_wire_null_policy]] memory.
|
|
/// </summary>
|
|
[JsonPropertyName("gacha_point")]
|
|
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
|
|
[Key("gacha_point")]
|
|
public PackGachaPointDto? GachaPoint { get; set; }
|
|
|
|
[JsonPropertyName("is_pre_release")]
|
|
[Key("is_pre_release")]
|
|
public bool IsPreRelease { get; set; }
|
|
|
|
[JsonPropertyName("exists_purchase_reward")]
|
|
[Key("exists_purchase_reward")]
|
|
public bool ExistsPurchaseReward { get; set; }
|
|
|
|
[JsonPropertyName("is_new")]
|
|
[Key("is_new")]
|
|
public bool IsNew { get; set; }
|
|
|
|
/// <summary>
|
|
/// Prod sends an object <c>{"sales_period_time":"..."}</c> when set and an array <c>[]</c>
|
|
/// when unset. v1 always emits an empty object when the field is null on the entity —
|
|
/// matches the active-window case and the client tolerates both shapes via
|
|
/// <c>ShopExpirtyInfo</c>'s LitJson parser. Revisit if a capture proves otherwise.
|
|
///
|
|
/// TODO(2026-05-28): the prod tutorial capture has each active pack with
|
|
/// <c>"sales_period_info": {"sales_period_time": "<complete_date>"}</c> — i.e., the
|
|
/// pack's <c>complete_date</c> echoed inside the object. Our controller emits <c>{}</c>
|
|
/// which the client tolerates (the tutorial flow doesn't filter on this field), but for
|
|
/// wire fidelity we should populate it from <c>PackConfigEntry.CompleteDate</c>. While
|
|
/// doing that, also retype this field from <c>Dictionary<string, string?></c> to a
|
|
/// typed <c>PackSalesPeriodInfoDto { string SalesPeriodTime }</c> — the current dict
|
|
/// shape is the lazy-key anti-pattern documented in
|
|
/// <c>feedback_no_lazy_response_dicts</c>. Deferred from the tutorial-bringup pass
|
|
/// because it doesn't gate any observable flow.
|
|
/// </summary>
|
|
[JsonPropertyName("sales_period_info")]
|
|
[Key("sales_period_info")]
|
|
public Dictionary<string, string?> SalesPeriodInfo { get; set; } = new();
|
|
|
|
[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; }
|
|
}
|