Files
SVSimServer/SVSim.Database/Models/Viewer.cs
gamer147 5c1db83967 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>
2026-06-27 20:06:46 -04:00

114 lines
4.3 KiB
C#

using System.ComponentModel.DataAnnotations.Schema;
using SVSim.Database.Common;
using Microsoft.EntityFrameworkCore;
namespace SVSim.Database.Models;
/// <summary>
/// A user within the game system.
/// </summary>
[Index(nameof(ShortUdid))]
[Index(nameof(Udid), IsUnique = true)]
public class Viewer : BaseEntity<long>
{
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public override long Id { get; set; }
/// <summary>
/// This user's name displayed in game.
/// </summary>
public string DisplayName { get; set; } = String.Empty;
/// <summary>
/// This user's short identifier.
/// </summary>
public long ShortUdid { get; set; }
/// <summary>
/// The client's full UDID (AES key for the wire protocol). Set when the viewer is created
/// via <c>/tool/signup</c>; null for viewers created via the admin Steam-import path. Unique
/// when present — the partial filter is declared in the migration.
/// </summary>
public Guid? Udid { get; set; }
public DateTime LastLogin { get; set; }
/// <summary>
/// UTC instant of the most recent daily-login-bonus claim. Null = never claimed.
/// Compared against JST 02:00 day boundary via JstPeriod.DayKey to decide eligibility.
/// </summary>
public DateTime? LastLoginBonusClaimedAt { get; set; }
/// <summary>
/// 1-based position in the Normal login-bonus cycle. 0 = never claimed (first claim
/// will write 1). Wraps back to 1 after reaching cycle length (LoginBonusConfig.Normal.Count).
/// </summary>
public int LoginBonusStreak { get; set; }
/// <summary>BGType enum: 0=Deck, 1=CustomBG, 2=RandomBG. Default 0 = follow equipped deck's leader skin.</summary>
public int MyPageBgSelectType { get; set; }
/// <summary>The single chosen MyPageBG cosmetic id, used when SelectType=CustomBG. 0 = none.</summary>
public int MyPageBgId { get; set; }
#region Owned
public ViewerInfo Info { get; set; } = new ViewerInfo();
public ViewerMissionData MissionData { get; set; } = new ViewerMissionData();
public ViewerCurrency Currency { get; set; } = new ViewerCurrency();
public List<ViewerClassData> Classes { get; set; } = new List<ViewerClassData>();
#endregion
#region Collection
public List<ShadowverseDeckEntry> Decks { get; set; } = new List<ShadowverseDeckEntry>();
public List<OwnedCardEntry> Cards { get; set; } = new List<OwnedCardEntry>();
public List<LeaderSkinEntry> LeaderSkins { get; set; } = new List<LeaderSkinEntry>();
public List<DegreeEntry> Degrees { get; set; } = new List<DegreeEntry>();
public List<EmblemEntry> Emblems { get; set; } = new List<EmblemEntry>();
public List<OwnedItemEntry> Items { get; set; } = new List<OwnedItemEntry>();
public List<SleeveEntry> Sleeves { get; set; } = new List<SleeveEntry>();
public List<MyPageBackgroundEntry> MyPageBackgrounds { get; set; } = new List<MyPageBackgroundEntry>();
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>();
public List<ViewerGachaPointBalance> GachaPointBalances { get; set; } = new List<ViewerGachaPointBalance>();
public List<ViewerGachaPointReceived> GachaPointReceived { get; set; } = new List<ViewerGachaPointReceived>();
public List<ViewerBuildDeckProductPurchase> BuildDeckPurchases { get; set; } = new List<ViewerBuildDeckProductPurchase>();
public List<ViewerMission> Missions { get; set; } = new List<ViewerMission>();
public List<ViewerAchievement> Achievements { get; set; } = new List<ViewerAchievement>();
public List<ViewerEventCounter> EventCounters { get; set; } = new List<ViewerEventCounter>();
#endregion
#region Navigation Properties
public List<SocialAccountConnection> SocialAccountConnections { get; set; } = new List<SocialAccountConnection>();
public int? GuildId { get; set; }
public SVSim.Database.Entities.Guild.Guild? Guild { get; set; }
#endregion
}