Deck list work

This commit is contained in:
gamer147
2026-05-23 19:57:34 -04:00
parent 66184b3685
commit d3b2970e11
41 changed files with 70683 additions and 81 deletions

View File

@@ -0,0 +1,201 @@
{
"data_headers": {
"sid": "ac631c29b5f5d07ed5fb6712ad8623c31779553960",
"short_udid": 411054851,
"viewer_id": 906243102,
"servertime": 1779553960,
"result_code": 1
},
"data": {
"10011": {
"record_id": "21",
"id": "8",
"store_product_id": "10011",
"name": "60-crystal set",
"text": "Purchase 60 Crystals",
"price": "0.99",
"charge_crystal_num": "60",
"free_crystal_num": "0",
"purchase_limit": "999999999",
"special_shop_flag": "0",
"image_name": "thumbnail_crystal",
"start_time": "2022-10-05 15:00:00",
"end_time": "2030-03-01 14:59:59",
"remaining_time": "0",
"is_resale_product": "0",
"resale_start_date": "",
"purchase_num_current": 0
},
"30011": {
"record_id": "26",
"id": "10",
"store_product_id": "30011",
"name": "670-crystal set",
"text": "Purchase 670 Crystals",
"price": "10.99",
"charge_crystal_num": "670",
"free_crystal_num": "0",
"purchase_limit": "999999999",
"special_shop_flag": "0",
"image_name": "thumbnail_crystal",
"start_time": "2022-10-05 15:00:00",
"end_time": "2030-03-01 14:59:59",
"remaining_time": "0",
"is_resale_product": "0",
"resale_start_date": "",
"purchase_num_current": 0
},
"40000": {
"record_id": "27",
"id": "4",
"store_product_id": "40000",
"name": "1200-crystal set",
"text": "Purchase 1200 Crystals",
"price": "20.99",
"charge_crystal_num": "1200",
"free_crystal_num": "0",
"purchase_limit": "999999999",
"special_shop_flag": "0",
"image_name": "thumbnail_crystal",
"start_time": "2015-03-01 15:00:00",
"end_time": "2030-03-01 14:59:59",
"remaining_time": "0",
"is_resale_product": "0",
"resale_start_date": "",
"purchase_num_current": 0
},
"50000": {
"record_id": "28",
"id": "5",
"store_product_id": "50000",
"name": "2400-crystal set",
"text": "Purchase 2400 Crystals",
"price": "39.99",
"charge_crystal_num": "2400",
"free_crystal_num": "0",
"purchase_limit": "999999999",
"special_shop_flag": "0",
"image_name": "thumbnail_crystal",
"start_time": "2015-03-01 15:00:00",
"end_time": "2030-03-01 14:59:59",
"remaining_time": "0",
"is_resale_product": "0",
"resale_start_date": "",
"purchase_num_current": 0
},
"60000": {
"record_id": "29",
"id": "6",
"store_product_id": "60000",
"name": "5000-crystal set",
"text": "Purchase 5000 Crystals",
"price": "79.99",
"charge_crystal_num": "5000",
"free_crystal_num": "0",
"purchase_limit": "999999999",
"special_shop_flag": "0",
"image_name": "thumbnail_crystal",
"start_time": "2015-03-01 15:00:00",
"end_time": "2030-03-01 14:59:59",
"remaining_time": "0",
"is_resale_product": "0",
"resale_start_date": "",
"purchase_num_current": 0
},
"70011": {
"record_id": "24",
"id": "9",
"store_product_id": "70011",
"name": "350-crystal set",
"text": "Purchase 350 Crystals",
"price": "5.99",
"charge_crystal_num": "350",
"free_crystal_num": "0",
"purchase_limit": "999999999",
"special_shop_flag": "0",
"image_name": "thumbnail_crystal",
"start_time": "2022-10-05 15:00:00",
"end_time": "2030-03-01 14:59:59",
"remaining_time": "0",
"is_resale_product": "0",
"resale_start_date": "",
"purchase_num_current": 0
},
"80000": {
"record_id": "30",
"id": "800",
"store_product_id": "80000",
"name": "1200-crystal and Legendary set",
"text": "Purchase 1200 Crystals and Legendary set",
"price": "20.99",
"charge_crystal_num": "1200",
"free_crystal_num": "0",
"purchase_limit": "3",
"special_shop_flag": "1",
"image_name": "thumbnail_crystal_strong",
"start_time": "2018-01-01 00:00:00",
"end_time": "2019-03-19 16:15:17",
"remaining_time": "604800",
"is_resale_product": "0",
"resale_start_date": "",
"purchase_num_current": 0
},
"98900": {
"record_id": "19",
"id": "989",
"store_product_id": "98900",
"name": "[b]1-Time Deal![/b] 1000-crystal set",
"text": "Purchase 1000 Crystals",
"price": "15.99",
"charge_crystal_num": "1000",
"free_crystal_num": "0",
"purchase_limit": "1",
"special_shop_flag": "0",
"image_name": "thumbnail_crystal_strong",
"start_time": "2026-04-01 02:00:00",
"end_time": "2026-07-01 01:59:59",
"remaining_time": "0",
"is_resale_product": "1",
"resale_start_date": "2026-04-01 02:00:00",
"purchase_num_current": 0
},
"99200": {
"record_id": "3",
"id": "992",
"store_product_id": "99200",
"name": "[b]One-time Deal![/b] 800-crystal set",
"text": "Purchase 800 Crystals",
"price": "7.99",
"charge_crystal_num": "800",
"free_crystal_num": "0",
"purchase_limit": "1",
"special_shop_flag": "0",
"image_name": "thumbnail_crystal_strong",
"start_time": "2018-01-30 04:00:00",
"end_time": "2030-03-01 14:59:59",
"remaining_time": "0",
"is_resale_product": "0",
"resale_start_date": "",
"purchase_num_current": 0
},
"99400": {
"record_id": "10",
"id": "994",
"store_product_id": "99400",
"name": "[b]Special Offer![/b] 7500-crystal set (3 times per person)",
"text": "Purchase 7500 Crystals",
"price": "79.99",
"charge_crystal_num": "7500",
"free_crystal_num": "0",
"purchase_limit": "3",
"special_shop_flag": "0",
"image_name": "thumbnail_crystal_strong",
"start_time": "2017-06-01 06:00:00",
"end_time": "2030-03-01 14:59:59",
"remaining_time": "0",
"is_resale_product": "0",
"resale_start_date": "",
"purchase_num_current": 2
}
}
}

View File

@@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Logging.Abstractions;
using SVSim.Database;
namespace SVSim.Bootstrap;
/// <summary>
/// Lets `dotnet ef migrations add` instantiate SVSimDbContext at design time. The runtime ctor
/// takes an ILogger which EF's tooling can't resolve without DI; this factory bypasses that.
/// Connection string here only needs to be valid Npgsql syntax — EF doesn't actually connect
/// during migration scaffolding.
/// </summary>
public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<SVSimDbContext>
{
public SVSimDbContext CreateDbContext(string[] args)
{
var options = new DbContextOptionsBuilder<SVSimDbContext>()
.UseNpgsql("Host=localhost;Database=svsim;Username=postgres;password=postgres")
.Options;
return new SVSimDbContext(NullLogger<SVSimDbContext>.Instance, options);
}
}

View File

@@ -27,6 +27,7 @@ public class GlobalsImporter
JsonElement? loadIndex = LoadCapture(capturesDir, "load-index");
JsonElement? mypageIndex = LoadCapture(capturesDir, "mypage-index");
JsonElement? deckInfo = LoadCapture(capturesDir, "deck-info");
JsonElement? paymentItemList = LoadCapture(capturesDir, "payment-item-list");
int total = 0;
@@ -54,6 +55,7 @@ public class GlobalsImporter
total += await ImportColosseum(context, mypageIndex.Value);
total += await ImportSealed(context, mypageIndex.Value);
total += await ImportMasterPointRankingPeriod(context, mypageIndex.Value);
total += await ImportRoomTypeInSession(context, mypageIndex.Value);
}
if (deckInfo.HasValue)
@@ -62,6 +64,11 @@ public class GlobalsImporter
total += await ImportDefaultLeaderSkinSettings(context, deckInfo.Value);
}
if (paymentItemList.HasValue)
{
total += await ImportPaymentItems(context, paymentItemList.Value);
}
await context.SaveChangesAsync();
Console.WriteLine($"[GlobalsImporter] Done: {total} total rows changed.");
return total;
@@ -550,6 +557,34 @@ public class GlobalsImporter
return 1;
}
// ---------- Mypage: Room Type In Session (special deck formats) ----------
private async Task<int> ImportRoomTypeInSession(SVSimDbContext context, JsonElement mypage)
{
if (!mypage.TryGetProperty("room_type_in_session", out var rt) || rt.ValueKind != JsonValueKind.Object) return 0;
if (!rt.TryGetProperty("special_deck_format_list", out var arr) || arr.ValueKind != JsonValueKind.Array) return 0;
// Same shape semantics as Banners — the wire has no stable id, treat the capture as
// authoritative and clear-and-rewrite with a synthetic ordinal.
var existing = await context.SpecialDeckFormats.ToListAsync();
context.SpecialDeckFormats.RemoveRange(existing);
int created = 0;
int idx = 1;
foreach (var el in arr.EnumerateArray())
{
context.SpecialDeckFormats.Add(new SpecialDeckFormatEntry
{
Id = idx++,
DeckFormat = GetString(el, "deck_format"),
EndTime = ParseWireDateTime(GetString(el, "end_time"))
});
created++;
}
Console.WriteLine($"[GlobalsImporter] SpecialDeckFormats: {(existing.Count > 0 ? $"-{existing.Count}/" : "")}+{created}");
return created;
}
// ---------- Deck/info: Default Decks ----------
private async Task<int> ImportDefaultDecks(SVSimDbContext context, JsonElement deckInfo)
@@ -611,6 +646,54 @@ public class GlobalsImporter
return created + updated;
}
// ---------- Payment: Item list (Steam/PC storefront, dict-keyed by store_product_id) ----------
private async Task<int> ImportPaymentItems(SVSimDbContext context, JsonElement payment)
{
// The payment-item-list capture's `data` IS the product dict (no nested key like banner/colosseum).
// LoadCapture already unwrapped `data` for us, so iterate the dict directly.
if (payment.ValueKind != JsonValueKind.Object) return 0;
var existing = await context.PaymentItems.ToDictionaryAsync(e => e.Id);
int created = 0, updated = 0;
foreach (var kv in payment.EnumerateObject())
{
var v = kv.Value;
if (v.ValueKind != JsonValueKind.Object) continue;
int recordId = GetInt(v, "record_id");
if (recordId == 0) continue;
var entry = existing.TryGetValue(recordId, out var ex) ? ex : new PaymentItemEntry { Id = recordId };
entry.ProductId = GetInt(v, "id");
entry.StoreProductId = GetLong(v, "store_product_id");
entry.Name = GetString(v, "name");
entry.Text = GetString(v, "text");
entry.Price = ParseDecimal(GetString(v, "price"));
entry.ChargeCrystalNum = GetInt(v, "charge_crystal_num");
entry.FreeCrystalNum = GetInt(v, "free_crystal_num");
entry.PurchaseLimit = GetInt(v, "purchase_limit");
entry.SpecialShopFlag = GetInt(v, "special_shop_flag");
entry.ImageName = GetString(v, "image_name");
entry.StartTime = ParseWireDateTime(GetString(v, "start_time"));
entry.EndTime = ParseWireDateTime(GetString(v, "end_time"));
entry.RemainingTime = GetInt(v, "remaining_time");
entry.IsResaleProduct = GetInt(v, "is_resale_product");
// resale_start_date is "" when unset — store null rather than DateTime.MinValue so the
// controller can decide whether to emit "" or a real date string.
string resaleRaw = GetString(v, "resale_start_date");
entry.ResaleStartDate = string.IsNullOrWhiteSpace(resaleRaw) ? null : ParseWireDateTime(resaleRaw);
if (ex is null) { context.PaymentItems.Add(entry); created++; }
else updated++;
}
Console.WriteLine($"[GlobalsImporter] PaymentItems: +{created}/~{updated}");
return created + updated;
}
private static decimal ParseDecimal(string s) =>
decimal.TryParse(s, System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out var d) ? d : 0m;
// ---------- Helpers ----------
private static void WarnOrphans(string label, int count)

View File

@@ -13,6 +13,22 @@
<Content Include="Data\prod-captures\*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<!--
Seed CSVs live in SVSim.EmulatedEntrypoint/Data — link them here so `dotnet ef migrations add`
(which uses Bootstrap as the startup project) finds the same files at AppContext.BaseDirectory.
Otherwise BaseDataSeeder.Seed short-circuits, the design-time model has no HasData rows, and
every migration diff wants to DeleteData/InsertData for all of them.
-->
<Content Include="..\SVSim.EmulatedEntrypoint\Data\*.csv" Link="Data\%(Filename)%(Extension)">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class MypageRoomTypeInSession : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "SpecialDeckFormats",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false),
DeckFormat = table.Column<string>(type: "text", nullable: false),
EndTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_SpecialDeckFormats", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SpecialDeckFormats");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class MypagePaymentItems : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PaymentItems",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false),
ProductId = table.Column<int>(type: "integer", nullable: false),
StoreProductId = table.Column<long>(type: "bigint", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Text = table.Column<string>(type: "text", nullable: false),
Price = table.Column<decimal>(type: "numeric", nullable: false),
ChargeCrystalNum = table.Column<int>(type: "integer", nullable: false),
FreeCrystalNum = table.Column<int>(type: "integer", nullable: false),
PurchaseLimit = table.Column<int>(type: "integer", nullable: false),
SpecialShopFlag = table.Column<int>(type: "integer", nullable: false),
ImageName = table.Column<string>(type: "text", nullable: false),
StartTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
EndTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
RemainingTime = table.Column<int>(type: "integer", nullable: false),
IsResaleProduct = table.Column<int>(type: "integer", nullable: false),
ResaleStartDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PaymentItems", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PaymentItems");
}
}
}

View File

@@ -25422,6 +25422,70 @@ namespace SVSim.Database.Migrations
b.ToTable("MyRotationSettings");
});
modelBuilder.Entity("SVSim.Database.Models.PaymentItemEntry", b =>
{
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<int>("ChargeCrystalNum")
.HasColumnType("integer");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DateUpdated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("EndTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("FreeCrystalNum")
.HasColumnType("integer");
b.Property<string>("ImageName")
.IsRequired()
.HasColumnType("text");
b.Property<int>("IsResaleProduct")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<decimal>("Price")
.HasColumnType("numeric");
b.Property<int>("ProductId")
.HasColumnType("integer");
b.Property<int>("PurchaseLimit")
.HasColumnType("integer");
b.Property<int>("RemainingTime")
.HasColumnType("integer");
b.Property<DateTime?>("ResaleStartDate")
.HasColumnType("timestamp with time zone");
b.Property<int>("SpecialShopFlag")
.HasColumnType("integer");
b.Property<DateTime>("StartTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("StoreProductId")
.HasColumnType("bigint");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("PaymentItems");
});
modelBuilder.Entity("SVSim.Database.Models.PreReleaseInfo", b =>
{
b.Property<int>("Id")
@@ -33873,6 +33937,29 @@ namespace SVSim.Database.Migrations
});
});
modelBuilder.Entity("SVSim.Database.Models.SpecialDeckFormatEntry", b =>
{
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DateUpdated")
.HasColumnType("timestamp with time zone");
b.Property<string>("DeckFormat")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("EndTime")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("SpecialDeckFormats");
});
modelBuilder.Entity("SVSim.Database.Models.SpotCardEntry", b =>
{
b.Property<long>("Id")

View File

@@ -0,0 +1,51 @@
using SVSim.Database.Common;
namespace SVSim.Database.Models;
/// <summary>
/// One row of the Steam/PC storefront item list from /payment_pc/item_list data. Singleton per
/// product. Id is the wire's <c>record_id</c> (prod's auto-increment, genuinely stable across
/// captures — same upsert-by-wire-id pattern as MasterPointRankingPeriodEntry, not the synthetic-
/// ordinal Banner pattern).
///
/// All numeric fields land in typed columns; the controller stringifies them on the way out to
/// match prod's PHP-stringified wire convention.
/// </summary>
public class PaymentItemEntry : BaseEntity<int>
{
/// <summary>Internal product id (different from store_product_id). Used by the client at
/// PaymentItemListTask.cs:50,58,64,67 as a per-tier discriminator.</summary>
public int ProductId { get; set; }
/// <summary>User-visible SKU (e.g. 10011 for "60-crystal set"). Wire dict key.</summary>
public long StoreProductId { get; set; }
public string Name { get; set; } = string.Empty;
public string Text { get; set; } = string.Empty;
public decimal Price { get; set; }
public int ChargeCrystalNum { get; set; }
public int FreeCrystalNum { get; set; }
public int PurchaseLimit { get; set; }
/// <summary>0/1 — special_shop_flag on the wire (stringified).</summary>
public int SpecialShopFlag { get; set; }
public string ImageName { get; set; } = string.Empty;
public DateTime StartTime { get; set; }
public DateTime EndTime { get; set; }
public int RemainingTime { get; set; }
/// <summary>0/1 — is_resale_product on the wire (stringified).</summary>
public int IsResaleProduct { get; set; }
/// <summary>Nullable — prod sends empty string when unset; we store null and emit "".</summary>
public DateTime? ResaleStartDate { get; set; }
}

View File

@@ -0,0 +1,17 @@
using SVSim.Database.Common;
namespace SVSim.Database.Models;
/// <summary>
/// One entry from /mypage/index data.room_type_in_session.special_deck_format_list. A list of deck-format
/// codes that have a current "special" window (e.g. format "5" valid until 2030-06-26). Id is a synthetic
/// ordinal — the wire has no explicit identifier, and ImportRoomTypeInSession follows the same clear-and-
/// rewrite pattern as ImportBanners.
/// </summary>
public class SpecialDeckFormatEntry : BaseEntity<int>
{
/// <summary>Wire is string per prod's PHP convention even though it looks numeric (e.g. "5").</summary>
public string DeckFormat { get; set; } = string.Empty;
public DateTime EndTime { get; set; }
}

View File

@@ -28,6 +28,27 @@ public class DeckRepository : IDeckRepository
?? new List<ShadowverseDeckEntry>();
}
public async Task<Dictionary<Format, List<ShadowverseDeckEntry>>> GetDecksByFormats(long viewerId, IEnumerable<Format> formats)
{
var requested = formats.ToHashSet();
var viewer = await _dbContext.Viewers
.AsNoTracking()
.Include(v => v.Decks).ThenInclude(d => d.Class)
.Include(v => v.Decks).ThenInclude(d => d.Sleeve)
.Include(v => v.Decks).ThenInclude(d => d.LeaderSkin)
.FirstOrDefaultAsync(v => v.Id == viewerId);
// Seed every requested format with an empty list so callers iterate without null checks.
var result = requested.ToDictionary(f => f, _ => new List<ShadowverseDeckEntry>());
if (viewer is null) return result;
foreach (var deck in viewer.Decks.Where(d => requested.Contains(d.Format)).OrderBy(d => d.Number))
{
result[deck.Format].Add(deck);
}
return result;
}
public async Task<ShadowverseDeckEntry?> GetDeck(long viewerId, Format format, int deckNo)
{
var viewer = await _dbContext.Viewers

View File

@@ -6,6 +6,14 @@ namespace SVSim.Database.Repositories.Deck;
public interface IDeckRepository
{
Task<List<ShadowverseDeckEntry>> GetDecks(long viewerId, Format format);
/// <summary>
/// Bulk-fetch viewer decks grouped by format. Returns a dict keyed by every format in
/// <paramref name="formats"/> — missing formats map to empty lists so callers don't need
/// dict-existence checks. Single viewer-load, no N+1.
/// </summary>
Task<Dictionary<Format, List<ShadowverseDeckEntry>>> GetDecksByFormats(long viewerId, IEnumerable<Format> formats);
Task<ShadowverseDeckEntry?> GetDeck(long viewerId, Format format, int deckNo);
Task<int> GetEmptyDeckNumber(long viewerId, Format format);
Task<ShadowverseDeckEntry> UpsertDeck(long viewerId, Format format, int deckNo, Action<ShadowverseDeckEntry> mutate);

View File

@@ -94,6 +94,12 @@ public class GlobalsRepository : IGlobalsRepository
.FirstOrDefaultAsync();
}
public Task<List<SpecialDeckFormatEntry>> GetActiveSpecialDeckFormats() =>
_dbContext.SpecialDeckFormats.AsNoTracking().OrderBy(e => e.Id).ToListAsync();
public Task<List<PaymentItemEntry>> GetPaymentItems() =>
_dbContext.PaymentItems.AsNoTracking().OrderBy(e => e.Id).ToListAsync();
public Task<List<MaintenanceCardEntry>> GetMaintenanceCards() =>
_dbContext.MaintenanceCards.AsNoTracking().ToListAsync();

View File

@@ -26,6 +26,8 @@ public interface IGlobalsRepository
Task<ColosseumConfig?> GetCurrentColosseum();
Task<SealedConfig?> GetCurrentSealedSeason();
Task<MasterPointRankingPeriodEntry?> GetCurrentMasterPointPeriod();
Task<List<SpecialDeckFormatEntry>> GetActiveSpecialDeckFormats();
Task<List<PaymentItemEntry>> GetPaymentItems();
Task<List<MaintenanceCardEntry>> GetMaintenanceCards();
Task<List<FeatureMaintenanceEntry>> GetFeatureMaintenances();
Task<PreReleaseInfo?> GetPreReleaseInfo();

View File

@@ -54,6 +54,8 @@ public class SVSimDbContext : DbContext
public DbSet<ColosseumConfig> Colosseums => Set<ColosseumConfig>();
public DbSet<SealedConfig> SealedSeasons => Set<SealedConfig>();
public DbSet<MasterPointRankingPeriodEntry> MasterPointRankingPeriods => Set<MasterPointRankingPeriodEntry>();
public DbSet<SpecialDeckFormatEntry> SpecialDeckFormats => Set<SpecialDeckFormatEntry>();
public DbSet<PaymentItemEntry> PaymentItems => Set<PaymentItemEntry>();
public DbSet<MaintenanceCardEntry> MaintenanceCards => Set<MaintenanceCardEntry>();
public DbSet<FeatureMaintenanceEntry> FeatureMaintenances => Set<FeatureMaintenanceEntry>();
public DbSet<PreReleaseInfo> PreReleaseInfos => Set<PreReleaseInfo>();

View File

@@ -44,15 +44,34 @@ public class DeckController : SVSimController
public async Task<ActionResult<DeckListResponse>> Info(DeckInfoRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var decks = await _deckRepository.GetDecks(viewerId, AsFormat(request.DeckFormat));
return await BuildDeckListResponseAsync(viewerId, AsFormat(request.DeckFormat));
}
// Globals — same shape every call; could be cached if it becomes a hotspot.
[HttpPost("my_list")]
public async Task<ActionResult<DeckListResponse>> MyList(DeckFormatRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
return await BuildDeckListResponseAsync(viewerId, AsFormat(request.DeckFormat));
}
/// <summary>
/// Shared hydration for <c>/deck/info</c> and <c>/deck/my_list</c> — both endpoints return the
/// same <see cref="DeckListResponse"/> DTO and the client's DeckInfoTask.Parse / DeckMyListTask.Parse
/// are identical (both call <c>DeckGroupListData(jsonData, format)</c>).
///
/// Wire shape swaps based on the request format. When the client asks for All-format
/// (<c>deck_format=0</c>), prod emits per-format keys (<c>user_deck_rotation</c>, etc.);
/// for a specific format request, prod emits a single <c>user_deck_list</c>. The client's
/// <c>DeckListUtility.ParseDeckInfoResponceData</c> branches on these two shapes, so the
/// controller mirrors it exactly.
/// </summary>
private async Task<DeckListResponse> BuildDeckListResponseAsync(long viewerId, Format requestFormat)
{
var defaultDecks = await _globalsRepository.GetDefaultDecks();
var leaderSkinSettings = await _globalsRepository.GetDefaultLeaderSkinSettings();
return new DeckListResponse
var response = new DeckListResponse
{
UserDeckList = decks.Select(d => new UserDeck(d)).ToList(),
DefaultDeckList = defaultDecks.ToDictionary(
d => d.Id.ToString(),
d => new DefaultDeck
@@ -63,6 +82,11 @@ public class DeckController : SVSimController
LeaderSkinId = d.LeaderSkinId,
DeckName = d.DeckName,
CardIdArray = System.Text.Json.JsonSerializer.Deserialize<List<long>>(d.CardIdArray, JsonbReadOptions) ?? new(),
// TODO(deck-stub): wire from real per-deck state once user maintenance / availability tracking lands.
// Prod emits is_complete_deck=1, is_available_deck=1, maintenance_card_ids=[] for the 8 starter decks.
IsCompleteDeck = 1,
IsAvailableDeck = 1,
MaintenanceCardIds = new(),
}),
UserLeaderSkinSettingList = leaderSkinSettings.ToDictionary(
s => s.Id.ToString(),
@@ -77,17 +101,25 @@ public class DeckController : SVSimController
TrialDeckList = new(),
MaintenanceCardList = new(), // sourced from same place as /load/index when wired
};
}
[HttpPost("my_list")]
public async Task<ActionResult<DeckListResponse>> MyList(DeckFormatRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var decks = await _deckRepository.GetDecks(viewerId, AsFormat(request.DeckFormat));
return new DeckListResponse
if (requestFormat == Format.All)
{
UserDeckList = decks.Select(d => new UserDeck(d)).ToList()
};
// Prod's All-format response emits these three per-format lists (each [] for fresh viewers).
// The PreRotation / Crossover / Avatar siblings exist in client code but prod omits them
// for our profile; we mirror that omission and leave the nullable DTO fields unset.
var formats = new[] { Format.Rotation, Format.Unlimited, Format.MyRotation };
var byFormat = await _deckRepository.GetDecksByFormats(viewerId, formats);
response.UserDeckRotation = byFormat[Format.Rotation].Select(d => new UserDeck(d)).ToList();
response.UserDeckUnlimited = byFormat[Format.Unlimited].Select(d => new UserDeck(d)).ToList();
response.UserDeckMyRotation = byFormat[Format.MyRotation].Select(d => new UserDeck(d)).ToList();
}
else
{
var decks = await _deckRepository.GetDecks(viewerId, requestFormat);
response.UserDeckList = decks.Select(d => new UserDeck(d)).ToList();
}
return response;
}
[HttpPost("get_empty_deck_number")]

View File

@@ -1,5 +1,4 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Mvc;
using SVSim.Database.Enums;
using SVSim.Database.Models;
@@ -10,6 +9,7 @@ using SVSim.Database.Repositories.Collectibles;
using SVSim.Database.Repositories.Globals;
using SVSim.Database.Repositories.Viewer;
using SVSim.EmulatedEntrypoint.Constants;
using SVSim.EmulatedEntrypoint.Infrastructure;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses;
@@ -37,19 +37,6 @@ public class LoadController : SVSimController
new CardSetIdentifier { SetId = 10010 }
};
// The prod-captured globals JSON was seeded with snake_case_lower keys (see SVSim.Bootstrap
// GlobalsImporter — jsonb columns store the original capture verbatim). Deserialize-back must
// use the same naming policy so e.g. `card_pool_name` maps onto `CardPoolName`.
//
// AllowReadingFromString handles prod's PHP-backend convention of emitting numeric values
// as JSON strings (e.g. `"ability_id": "1"`). Numeric-typed DTO properties accept those.
private static readonly JsonSerializerOptions JsonbReadOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = false,
NumberHandling = JsonNumberHandling.AllowReadingFromString,
};
private readonly IViewerRepository _viewerRepository;
private readonly ICardRepository _cardRepository;
private readonly ICollectionRepository _collectionRepository;
@@ -257,7 +244,7 @@ public class LoadController : SVSimController
ArenaFormatInfo? format = null;
if (!string.IsNullOrEmpty(season.FormatInfo) && season.FormatInfo != "{}")
{
format = JsonSerializer.Deserialize<ArenaFormatInfo>(season.FormatInfo, JsonbReadOptions);
format = JsonSerializer.Deserialize<ArenaFormatInfo>(season.FormatInfo, JsonbReadOptions.Instance);
}
return new List<ArenaInfo>
@@ -298,13 +285,13 @@ public class LoadController : SVSimController
}),
Abilities = abilities.ToDictionary(
a => a.Id.ToString(),
a => JsonSerializer.Deserialize<MyRotationAbility>(a.Data, JsonbReadOptions) ?? new MyRotationAbility()),
a => JsonSerializer.Deserialize<MyRotationAbility>(a.Data, JsonbReadOptions.Instance) ?? new MyRotationAbility()),
ReprintedCards = settings.ToDictionary(
s => s.Id.ToString(),
s => JsonSerializer.Deserialize<Dictionary<string, int>>(s.ReprintedCardIds, JsonbReadOptions) ?? new()),
s => JsonSerializer.Deserialize<Dictionary<string, int>>(s.ReprintedCardIds, JsonbReadOptions.Instance) ?? new()),
Banlist = settings.ToDictionary(
s => s.Id.ToString(),
s => JsonSerializer.Deserialize<Dictionary<string, int>>(s.RestrictedCardIds, JsonbReadOptions) ?? new()),
s => JsonSerializer.Deserialize<Dictionary<string, int>>(s.RestrictedCardIds, JsonbReadOptions.Instance) ?? new()),
DisabledCardSets = new List<int>(), // prod 2026-05-23 emits empty list; refine if/when populated
Schedules = BuildMyRotationSchedules(),
};
@@ -369,9 +356,9 @@ public class LoadController : SVSimController
PreReleaseCardMasterId = pri.PreReleaseCardMasterId,
FreeMatchStartTime = pri.FreeMatchStartTime,
CardMasterId = pri.CardMasterId,
RotationCardSets = JsonSerializer.Deserialize<List<int>>(pri.RotationCardSetIdList, JsonbReadOptions) ?? new(),
ReprintedCardIds = JsonSerializer.Deserialize<Dictionary<string, string>>(pri.ReprintedBaseCardIds, JsonbReadOptions) ?? new(),
LatestReprintedCardIds = JsonSerializer.Deserialize<List<int>>(pri.LatestReprintedBaseCardIds, JsonbReadOptions) ?? new(),
RotationCardSets = JsonSerializer.Deserialize<List<int>>(pri.RotationCardSetIdList, JsonbReadOptions.Instance) ?? new(),
ReprintedCardIds = JsonSerializer.Deserialize<Dictionary<string, string>>(pri.ReprintedBaseCardIds, JsonbReadOptions.Instance) ?? new(),
LatestReprintedCardIds = JsonSerializer.Deserialize<List<int>>(pri.LatestReprintedBaseCardIds, JsonbReadOptions.Instance) ?? new(),
IsPreRotationFreeMatchTerm = pri.IsPreRotationFreeMatchTerm ? 1 : 0,
};
}

View File

@@ -1,8 +1,11 @@
using System.Globalization;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Globals;
using SVSim.Database.Repositories.Viewer;
using SVSim.EmulatedEntrypoint.Constants;
using SVSim.EmulatedEntrypoint.Infrastructure;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses;
@@ -11,6 +14,10 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
public class MyPageController : SVSimController
{
/// <summary>"yyyy-MM-dd HH:mm:ss" — prod's PHP convention. Used for wire-formatting DateTime
/// columns that the client parses via DateTime.Parse on its side.</summary>
private const string WireDateFormat = "yyyy-MM-dd HH:mm:ss";
private readonly IViewerRepository _viewerRepository;
private readonly IGlobalsRepository _globalsRepository;
@@ -38,9 +45,15 @@ public class MyPageController : SVSimController
var deviceHeader = Request.Headers["DEVICE"].FirstOrDefault();
int deviceType = int.TryParse(deviceHeader, out int parsed) ? parsed : 0;
// Stubs below are tagged TODO(mypage-stub). See the "Current server implementation"
// section of docs/api-spec/endpoints/post-login/mypage-index.md for the table of what
// each one would source from. Grep for "mypage-stub" to enumerate them.
// Hydrate all the globals slices in parallel-ish — they're independent reads.
var cfg = await _globalsRepository.GetGameConfiguration("default");
var colosseum = await _globalsRepository.GetCurrentColosseum();
var sealedSeason = await _globalsRepository.GetCurrentSealedSeason();
var masterPointPeriod = await _globalsRepository.GetCurrentMasterPointPeriod();
var bannerEntries = await _globalsRepository.GetBanners();
var specialDeckFormats = await _globalsRepository.GetActiveSpecialDeckFormats();
// Remaining stubs are tagged TODO(mypage-stub) — see docs/api-spec/endpoints/post-login/mypage-index.md.
return new MyPageIndexResponse
{
UserInfo = new UserInfo(deviceType, viewer),
@@ -55,6 +68,19 @@ public class MyPageController : SVSimController
ArenaInfo = await BuildArenaInfosAsync(),
IsArenaChallengePeriod = false, // TODO(mypage-stub): globals/ArenaSeason flag
IsAvailableColosseumFreeEntry = false, // TODO(mypage-stub): viewer + globals free-entry quota
ColosseumInfo = BuildColosseumInfo(colosseum),
SealedInfo = BuildSealedInfo(sealedSeason),
Banner = bannerEntries.Select(BuildBannerInfo).ToList(),
RoomTypeInSession = new RoomTypeInSession
{
SpecialDeckFormatList = specialDeckFormats
.Select(e => new SpecialDeckFormat
{
DeckFormat = e.DeckFormat,
EndTime = e.EndTime.ToString(WireDateFormat, CultureInfo.InvariantCulture)
})
.ToList()
},
Convention = new Convention // TODO(mypage-stub): viewer offline-event participation
{
IsJoinTournament = false,
@@ -62,36 +88,61 @@ public class MyPageController : SVSimController
},
UserConfig = new UserConfig(), // TODO(mypage-stub): persist viewer UserConfig
Quest = new Quest(), // TODO(mypage-stub): active Quest event + viewer flags
MasterPointRankingPeriod = new MasterPointRankingPeriod
{
// TODO(mypage-stub): source begin_time/end_time/period_num/necessary_score from the
// current Master Points season row in globals. Far-future fallback so the client's
// DateTime.Parse(end_time) succeeds and _masterResetNextTime gets seeded.
EndTime = "2030-01-01 00:00:00",
},
MasterPointRankingPeriod = BuildMasterPointRankingPeriod(masterPointPeriod),
PreReleaseStatus = 0, // TODO(mypage-stub): derive from PreReleaseInfo
UserMyPageInfo = new UserMyPageInfo // TODO(mypage-stub): viewer mypage BG selection
{
UserMyPageSetting = new MyPageBgSetting(),
},
BasicPuzzle = new BasicPuzzle { IsDisplayBadge = false }, // TODO(mypage-stub): viewer practice-puzzle progress
IsBattlePassPeriod = (await _globalsRepository.GetGameConfiguration("default")).IsBattlePassPeriod,
IsBattlePassPeriod = cfg.IsBattlePassPeriod,
SpecialCrystalInfo = new(), // TODO(mypage-stub): same shape/source as /load/index
// ColosseumInfo, ShopNotification, StoryNotification, IsHiddenBossAppeared all
// default-constructed by MyPageIndexResponse's field initializers.
// TODO(mypage-stub): wire colosseum_info from current Colosseum cup row.
// CompetitionInfo, ShopNotification, StoryNotification, GuildNotification, GatheringInfo,
// IsHiddenBossAppeared, SubBanner/SubBannerList/HomeDialogList/UserOfflineEvent/UserItemList,
// and the three explicit-null fields (TreasureInfo, LotteryPeriodInfo, AllCardEnabledPeriod)
// all rely on MyPageIndexResponse field initializers.
// TODO(mypage-stub): wire competition_info from active tournament row (default false fine until tournaments exist).
// TODO(mypage-stub): wire shop_notification from per-product shop-appeal state.
// TODO(mypage-stub): wire story_notification from viewer story progress.
// TODO(mypage-stub): wire is_hidden_boss_appeared from globals event flag.
// TODO(mypage-stub): per-viewer state for user_item_list, gathering_info.is_entry, guild_notification, user_offline_event, home_dialog_list.
};
}
/// <summary>
/// Same shape as LoadController.BuildArenaInfosAsync, but /mypage/index has no
/// Keys.Contains("arena_info") guard on the client (ArenaData(jsonData["arena_info"])
/// at MyPageTask.cs:55 indexes [0] unconditionally). When no current Take Two season is
/// seeded we fall back to a minimal one-entry list so the client's ArenaData ctor doesn't
/// crash with IndexOutOfRange.
/// Slim notification-delta endpoint — see MyPageRefreshResponse for the 3-field contract.
/// Client fires this once after main-menu UI settles (and a second time shortly after; both
/// calls get the same response). No state changes happen here; everything is read-only.
/// </summary>
[HttpPost("refresh")]
public async Task<ActionResult<MyPageRefreshResponse>> Refresh(MyPageRefreshRequest request)
{
var shortUdidClaim = User.Claims.FirstOrDefault(c => c.Type == ShadowverseClaimTypes.ShortUdidClaim)?.Value;
if (shortUdidClaim is null || !long.TryParse(shortUdidClaim, out long shortUdid))
{
return Unauthorized();
}
Viewer? viewer = await _viewerRepository.GetViewerByShortUdid(shortUdid);
if (viewer is null)
{
return NotFound();
}
return new MyPageRefreshResponse
{
FriendBattleInviteCount = 0, // TODO(mypage-stub): viewer room-invite count
ShopNotification = new ShopNotification(), // TODO(mypage-stub): per-product shop-appeal state
GatheringNotification = new GatheringNotification(), // empty matching message — correct for fresh viewers
};
}
/// <summary>
/// Mirrors LoadController.BuildArenaInfosAsync. /mypage/index has no Keys.Contains("arena_info")
/// guard (ArenaData(jsonData["arena_info"]) at MyPageTask.cs:55 indexes [0] unconditionally), and
/// the post-parse UI consumer (ChallengeEntry.SetChallengeInfo at ChallengeEntry.cs:35) reads
/// _twoPickData.ChallengeData which is only built when arena_info[0].format_info is present.
/// So we always populate format_info from the same ArenaSeason.FormatInfo jsonb /load/index uses.
/// </summary>
private async Task<List<ArenaInfo>> BuildArenaInfosAsync()
{
@@ -112,6 +163,12 @@ public class MyPageController : SVSimController
};
}
ArenaFormatInfo? format = null;
if (!string.IsNullOrEmpty(season.FormatInfo) && season.FormatInfo != "{}")
{
format = JsonSerializer.Deserialize<ArenaFormatInfo>(season.FormatInfo, JsonbReadOptions.Instance);
}
return new List<ArenaInfo>
{
new ArenaInfo
@@ -122,10 +179,115 @@ public class MyPageController : SVSimController
RupeeCost = season.RupyCost,
TicketCost = season.TicketCost,
IsJoin = season.IsJoin,
// format_info is intentionally omitted here — /mypage/index's ArenaData
// ctor only needs the top-level fields. /load/index round-trips it via
// JsonbReadOptions; pull it in if a downstream check ever needs it.
FormatInfo = format,
}
};
}
private ColosseumInfo BuildColosseumInfo(ColosseumConfig? row)
{
if (row is null) return new ColosseumInfo();
ColosseumSalesPeriodInfo sales = new();
if (!string.IsNullOrEmpty(row.SalesPeriodInfo) && row.SalesPeriodInfo != "{}")
{
sales = JsonSerializer.Deserialize<ColosseumSalesPeriodInfo>(row.SalesPeriodInfo, JsonbReadOptions.Instance)
?? new ColosseumSalesPeriodInfo();
}
return new ColosseumInfo
{
ColosseumId = row.ColosseumId,
IsDisplayTips = row.IsDisplayTips,
TipsId = row.TipsId,
CardPoolName = row.CardPoolName,
IsColosseumPeriod = row.IsColosseumPeriod,
IsRoundPeriod = row.IsRoundPeriod,
DeckFormat = row.DeckFormat,
IsNormalTwoPick = row.IsNormalTwoPick,
IsSpecialMode = row.IsSpecialMode,
IsAllCardEnabled = row.IsAllCardEnabled,
StartTime = row.StartTime.ToString(WireDateFormat, CultureInfo.InvariantCulture),
ColosseumName = row.ColosseumName,
NowRound = row.NowRound,
EndTime = row.EndTime.ToString(WireDateFormat, CultureInfo.InvariantCulture),
SalesPeriodInfo = sales,
};
}
private SealedInfo BuildSealedInfo(SealedConfig? row)
{
if (row is null) return new SealedInfo();
List<int> packInfo = new();
if (!string.IsNullOrEmpty(row.PackInfo) && row.PackInfo != "[]")
{
packInfo = JsonSerializer.Deserialize<List<int>>(row.PackInfo, JsonbReadOptions.Instance) ?? new();
}
SealedSalesPeriodInfo sales = new();
if (!string.IsNullOrEmpty(row.SalesPeriodInfo) && row.SalesPeriodInfo != "{}")
{
sales = JsonSerializer.Deserialize<SealedSalesPeriodInfo>(row.SalesPeriodInfo, JsonbReadOptions.Instance)
?? new SealedSalesPeriodInfo();
}
return new SealedInfo
{
Enable = row.Enable,
CrystalCost = row.CrystalCost,
RupyCost = row.RupyCost,
TicketCost = row.TicketCost,
IsJoin = row.IsJoin,
PackInfo = packInfo,
DeckUsingNumMin = row.DeckUsingNumMin,
ScheduleId = row.ScheduleId,
IsDeckCodeMaintenance = row.IsDeckCodeMaintenance,
SalesPeriodInfo = sales,
};
}
private static BannerInfo BuildBannerInfo(BannerEntry row)
{
List<string> imagePaths = new();
if (!string.IsNullOrEmpty(row.ImagePaths) && row.ImagePaths != "[]")
{
imagePaths = JsonSerializer.Deserialize<List<string>>(row.ImagePaths, JsonbReadOptions.Instance) ?? new();
}
return new BannerInfo
{
ImageName = row.ImageName,
Click = row.Click,
Status = row.Status,
// DB stores numeric, wire is string. PHP convention.
ChangeTime = row.ChangeTime.ToString(CultureInfo.InvariantCulture),
RemainingTime = row.RemainingTime.ToString(CultureInfo.InvariantCulture),
ImagePaths = imagePaths,
};
}
/// <summary>
/// Far-future fallback EndTime so the client's DateTime.Parse(end_time) succeeds and
/// Data.Load.data._masterResetNextTime gets seeded even when no globals row is present.
/// </summary>
private static MasterPointRankingPeriod BuildMasterPointRankingPeriod(MasterPointRankingPeriodEntry? row)
{
if (row is null)
{
return new MasterPointRankingPeriod
{
EndTime = "2030-01-01 00:00:00",
};
}
return new MasterPointRankingPeriod
{
Id = row.Id,
PeriodNum = row.PeriodNum,
NecessaryScore = row.NecessaryScore,
BeginTime = row.BeginTime.ToString(WireDateFormat, CultureInfo.InvariantCulture),
EndTime = row.EndTime.ToString(WireDateFormat, CultureInfo.InvariantCulture),
};
}
}

View File

@@ -0,0 +1,70 @@
using System.Globalization;
using Microsoft.AspNetCore.Mvc;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Globals;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// /payment_pc/* — Steam/PC store endpoints. Currently serves item_list (the storefront product
/// catalog); purchase flows (/payment_pc/finish etc.) are not yet implemented.
///
/// Route is explicit because the URL prefix doesn't match the controller name pattern
/// (SVSimController applies [Route("[controller]")] which would resolve to /payment).
/// </summary>
[Route("payment_pc")]
public class PaymentController : SVSimController
{
/// <summary>"yyyy-MM-dd HH:mm:ss" — prod's PHP datetime convention on the wire.</summary>
private const string WireDateFormat = "yyyy-MM-dd HH:mm:ss";
private readonly IGlobalsRepository _globalsRepository;
public PaymentController(IGlobalsRepository globalsRepository)
{
_globalsRepository = globalsRepository;
}
[HttpPost("item_list")]
public async Task<ActionResult<Dictionary<string, PaymentItemInfo>>> ItemList(PaymentItemListRequest request)
{
var items = await _globalsRepository.GetPaymentItems();
return items.ToDictionary(
row => row.StoreProductId.ToString(CultureInfo.InvariantCulture),
row => BuildPaymentItemInfo(row));
}
/// <summary>
/// Map a typed DB row to the all-strings wire shape prod uses. Typed columns let us query and
/// validate cleanly server-side; PHP-stringification happens here at the wire boundary.
/// </summary>
private static PaymentItemInfo BuildPaymentItemInfo(PaymentItemEntry row) => new()
{
RecordId = row.Id.ToString(CultureInfo.InvariantCulture),
Id = row.ProductId.ToString(CultureInfo.InvariantCulture),
StoreProductId = row.StoreProductId.ToString(CultureInfo.InvariantCulture),
Name = row.Name,
Text = row.Text,
// Prod price wire shape is e.g. "0.99" with up to 2 decimals. InvariantCulture renders the
// .NET decimal as "0.99" / "10.99" cleanly without trailing zeros from a scale of 4+.
Price = row.Price.ToString("0.##", CultureInfo.InvariantCulture),
ChargeCrystalNum = row.ChargeCrystalNum.ToString(CultureInfo.InvariantCulture),
FreeCrystalNum = row.FreeCrystalNum.ToString(CultureInfo.InvariantCulture),
PurchaseLimit = row.PurchaseLimit.ToString(CultureInfo.InvariantCulture),
SpecialShopFlag = row.SpecialShopFlag.ToString(CultureInfo.InvariantCulture),
ImageName = row.ImageName,
StartTime = row.StartTime.ToString(WireDateFormat, CultureInfo.InvariantCulture),
EndTime = row.EndTime.ToString(WireDateFormat, CultureInfo.InvariantCulture),
RemainingTime = row.RemainingTime.ToString(CultureInfo.InvariantCulture),
IsResaleProduct = row.IsResaleProduct.ToString(CultureInfo.InvariantCulture),
// Prod sends "" when no resale window is scheduled; otherwise the formatted date.
ResaleStartDate = row.ResaleStartDate is { } d
? d.ToString(WireDateFormat, CultureInfo.InvariantCulture)
: string.Empty,
// TODO(payment-stub): per-viewer count of this product's purchases. Hardcoded to 0 until
// viewer-purchase tracking lands. Fresh viewers always see 0 in prod anyway.
PurchaseNumCurrent = 0,
};
}

View File

@@ -0,0 +1,26 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Infrastructure;
/// <summary>
/// Shared System.Text.Json options for deserializing jsonb-passthrough columns into typed DTOs.
///
/// The prod-captured globals JSON was seeded with snake_case_lower keys (see SVSim.Bootstrap
/// GlobalsImporter — jsonb columns store the original capture verbatim). Deserialize-back must
/// use the same naming policy so e.g. `card_pool_name` maps onto `CardPoolName`.
///
/// AllowReadingFromString handles prod's PHP-backend convention of emitting numeric values
/// as JSON strings (e.g. `"ability_id": "1"`). Numeric-typed DTO properties accept those.
///
/// Used by LoadController and MyPageController (and any other controller that reads jsonb).
/// </summary>
public static class JsonbReadOptions
{
public static readonly JsonSerializerOptions Instance = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = false,
NumberHandling = JsonNumberHandling.AllowReadingFromString,
};
}

View File

@@ -0,0 +1,46 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
/// <summary>
/// One entry from /mypage/index data.banner — the home-screen promo carousel. Consumed by
/// MyPageBannerBase.BannerInfo.Parse(jsonData[i]) when the client iterates banner[i] (banner
/// access is TryGetValue-guarded but the per-entry parse is unconditional).
///
/// Prod-captured shape:
/// <code>
/// {"image_name":"banner_000788","click":"account_transition_with_two","status":"10",
/// "change_time":"10","remaining_time":"0","image_paths":[]}
/// </code>
///
/// Note: change_time, remaining_time, and status are strings on the wire (PHP convention) even
/// though they look numeric. The DB stores them in matching column types but the wire shape rules.
/// </summary>
[MessagePackObject]
public class BannerInfo
{
[JsonPropertyName("image_name")]
[Key("image_name")]
public string ImageName { get; set; } = string.Empty;
[JsonPropertyName("click")]
[Key("click")]
public string Click { get; set; } = string.Empty;
[JsonPropertyName("status")]
[Key("status")]
public string Status { get; set; } = string.Empty;
[JsonPropertyName("change_time")]
[Key("change_time")]
public string ChangeTime { get; set; } = string.Empty;
[JsonPropertyName("remaining_time")]
[Key("remaining_time")]
public string RemainingTime { get; set; } = string.Empty;
[JsonPropertyName("image_paths")]
[Key("image_paths")]
public List<string> ImagePaths { get; set; } = new();
}

View File

@@ -4,17 +4,44 @@ using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
/// <summary>
/// colosseum_info on /mypage/index, consumed by
/// ColosseumEntryInfoTask.SetColosseumInfo (Wizard/ColosseumEntryInfoTask.cs:99).
/// colosseum_info on /mypage/index, consumed by ColosseumEntryInfoTask.SetColosseumInfo
/// (Wizard/ColosseumEntryInfoTask.cs:99). The outer object is read unconditionally, and
/// is_colosseum_period gates everything else. When a cup IS active, the client reads
/// many more sub-fields inside the gate (deck_format, now_round, start_time, end_time,
/// sales_period_info, etc.) — we now mirror the full prod shape so the gate-true branch
/// works once we have colosseum data seeded.
///
/// The block is indexed unconditionally — it MUST be present, and
/// `is_colosseum_period` MUST be set. All other fields are only read inside the
/// `if (IsColosseumPeriod)` branch, so when no Take Two cup is active we emit
/// the minimum payload (is_colosseum_period=false) and leave the rest defaulted.
/// Prod-captured shape (15 fields):
/// <code>
/// {"colosseum_id":"165","is_display_tips":"0","tips_id":"0",
/// "card_pool_name":"Take Two (DragonbladeRivenbrandt)",
/// "is_colosseum_period":true,"is_round_period":true,"deck_format":"3",
/// "is_normal_two_pick":"1","is_special_mode":"10","is_all_card_enabled":0,
/// "start_time":"2026-05-21 06:00:00","colosseum_name":"Rivenbrandt Take Two Cup",
/// "now_round":"1","end_time":"2026-05-25 19:59:59",
/// "sales_period_info":{"sales_period_time":"2026-05-25 19:59:59"}}
/// </code>
/// </summary>
[MessagePackObject]
public class ColosseumInfo
{
[JsonPropertyName("colosseum_id")]
[Key("colosseum_id")]
public string ColosseumId { get; set; } = string.Empty;
/// <summary>Wire is "0"/"1" string. Client compares with == "1" (GetValueOrDefault-guarded).</summary>
[JsonPropertyName("is_display_tips")]
[Key("is_display_tips")]
public string IsDisplayTips { get; set; } = "0";
[JsonPropertyName("tips_id")]
[Key("tips_id")]
public string TipsId { get; set; } = "0";
[JsonPropertyName("card_pool_name")]
[Key("card_pool_name")]
public string CardPoolName { get; set; } = string.Empty;
[JsonPropertyName("is_colosseum_period")]
[Key("is_colosseum_period")]
public bool IsColosseumPeriod { get; set; }
@@ -23,11 +50,15 @@ public class ColosseumInfo
[Key("is_round_period")]
public bool IsRoundPeriod { get; set; }
/// <summary>
/// Wire is a stringified int in prod (e.g. "3"). DB stores as string. Client calls
/// <c>jsonData["deck_format"].ToInt()</c> inside the IsColosseumPeriod gate.
/// </summary>
[JsonPropertyName("deck_format")]
[Key("deck_format")]
public int DeckFormat { get; set; }
public string DeckFormat { get; set; } = "0";
/// <summary>Wire is "1"/"0" string in prod. Client compares with == "1".</summary>
/// <summary>Wire is "1"/"0" string. Client compares with == "1".</summary>
[JsonPropertyName("is_normal_two_pick")]
[Key("is_normal_two_pick")]
public string IsNormalTwoPick { get; set; } = "0";
@@ -37,7 +68,30 @@ public class ColosseumInfo
[Key("is_special_mode")]
public string IsSpecialMode { get; set; } = "0";
[JsonPropertyName("is_all_card_enabled")]
[Key("is_all_card_enabled")]
public int IsAllCardEnabled { get; set; }
/// <summary>"yyyy-MM-dd HH:mm:ss" wire format.</summary>
[JsonPropertyName("start_time")]
[Key("start_time")]
public string StartTime { get; set; } = string.Empty;
[JsonPropertyName("colosseum_name")]
[Key("colosseum_name")]
public string ColosseumName { get; set; } = string.Empty;
/// <summary>Round number as string (e.g. "1"). Client casts to int.</summary>
[JsonPropertyName("now_round")]
[Key("now_round")]
public string NowRound { get; set; } = "0";
/// <summary>"yyyy-MM-dd HH:mm:ss" wire format.</summary>
[JsonPropertyName("end_time")]
[Key("end_time")]
public string EndTime { get; set; } = string.Empty;
[JsonPropertyName("sales_period_info")]
[Key("sales_period_info")]
public ColosseumSalesPeriodInfo SalesPeriodInfo { get; set; } = new();
}

View File

@@ -0,0 +1,18 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
/// <summary>
/// Nested under /mypage/index data.colosseum_info.sales_period_info. Carries the wall-clock end of
/// the current cup's sales window. Captured from prod:
/// <c>"sales_period_info": { "sales_period_time": "2026-05-25 19:59:59" }</c>.
/// </summary>
[MessagePackObject]
public class ColosseumSalesPeriodInfo
{
/// <summary>Wire format is "yyyy-MM-dd HH:mm:ss" (prod's PHP convention, not ISO).</summary>
[JsonPropertyName("sales_period_time")]
[Key("sales_period_time")]
public string SalesPeriodTime { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,23 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
/// <summary>
/// Tournament-window block returned by /mypage/index. Client constructs
/// ArenaCompetition(base.ResponseData) at MyPageTask.cs:110, which then reads
/// `responseData["data"]["competition_info"]["is_competition_period"]`
/// unconditionally (ArenaCompetition.cs:232-233). The remaining fields
/// (deck_format, entry_start_time, freebie_status, featured_entry_reward_list,
/// etc.) are only read when IsCompetitionPeriod is true, so the minimum-viable
/// payload while we have no tournament implementation is just the bool=false.
/// Prod emits the same `{"is_competition_period":false}` shape when no
/// tournament is active.
/// </summary>
[MessagePackObject]
public class CompetitionInfo
{
[JsonPropertyName("is_competition_period")]
[Key("is_competition_period")]
public bool IsCompetitionPeriod { get; set; }
}

View File

@@ -16,11 +16,16 @@ public class Convention
public bool IsJoinTournament { get; set; }
/// <summary>
/// ISO datetime. Optional — omitted via WhenWritingNull when not set.
/// Client null-checks before parsing (MyPageTask.cs:59).
/// ISO datetime, or null when no recent tournament. Client does
/// `if (jsonData["convention"]["recent_start_date"] != null)` (MyPageTask.cs:59)
/// the key must be PRESENT (LitJson throws KeyNotFoundException on missing key);
/// the null check exists to detect "no recent tournament", not "field absent".
/// Override the global WhenWritingNull so the explicit null reaches the wire,
/// matching prod's `"recent_start_date":null` in the convention block.
/// </summary>
[JsonPropertyName("recent_start_date")]
[Key("recent_start_date")]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public string? RecentStartDate { get; set; }
[JsonPropertyName("is_admin_watch_user")]

View File

@@ -35,4 +35,19 @@ public class DefaultDeck
[JsonPropertyName("card_id_array")]
[Key("card_id_array")]
public List<long> CardIdArray { get; set; } = new();
/// <summary>0/1. Client reads via GetJsonBool(default true) in DeckData.Initialize. Prod always sends 1 for the 8 starter decks.</summary>
[JsonPropertyName("is_complete_deck")]
[Key("is_complete_deck")]
public int IsCompleteDeck { get; set; } = 1;
/// <summary>0/1. Read by downstream deck-edit UI (not by DeckData.Initialize itself). Prod always sends 1.</summary>
[JsonPropertyName("is_available_deck")]
[Key("is_available_deck")]
public int IsAvailableDeck { get; set; } = 1;
/// <summary>Card ids currently under maintenance (disabled). Empty for the 8 starter decks in prod.</summary>
[JsonPropertyName("maintenance_card_ids")]
[Key("maintenance_card_ids")]
public List<long> MaintenanceCardIds { get; set; } = new();
}

View File

@@ -3,10 +3,20 @@ using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
/// <summary>
/// gathering_info on /mypage/index — multiplayer-event participation state. Consumed by
/// GatheringMyPageInfo ctor (TryGetValue-guarded) but emitted unconditionally to match prod and
/// to keep post-parse UI consumers from reading nulls.
/// </summary>
[MessagePackObject]
public class GatheringInfo
{
[JsonPropertyName("has_invite")]
[Key("has_invite")]
public int HasInvite { get; set; }
}
/// <summary>Whether this viewer has entered the current gathering event. Per-viewer state — currently always 0.</summary>
[JsonPropertyName("is_entry")]
[Key("is_entry")]
public int IsEntry { get; set; }
}

View File

@@ -0,0 +1,26 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
/// <summary>
/// gathering_notification on /mypage/refresh — slim "matching established?" notification flag for
/// gathering events. Single field carrying either an empty string (no match) or the localized
/// "matching established" message (active match).
///
/// **Distinct from <see cref="GatheringInfo"/>**, which is what /mypage/index emits under the
/// <c>gathering_info</c> key — that DTO carries the viewer's full event participation state
/// (has_invite / is_entry). They share a topic ("gathering events") but solve different problems
/// and live at different wire keys; don't conflate them.
///
/// Consumed unconditionally at <c>MyPageRefreshTask.cs:31</c>:
/// <c>jsonData["data"]["gathering_notification"]["matching_established_message"].ToString()</c>.
/// </summary>
[MessagePackObject]
public class GatheringNotification
{
/// <summary>Empty string when no match — correct for fresh viewers and idle states. Prod sends "".</summary>
[JsonPropertyName("matching_established_message")]
[Key("matching_established_message")]
public string MatchingEstablishedMessage { get; set; } = string.Empty;
}

View File

@@ -5,20 +5,25 @@ namespace SVSim.EmulatedEntrypoint.Models.Dtos;
/// <summary>
/// guild_notification on /mypage/index. Consumed by
/// MyPageNotifications.GuildNotification.SetGuildNotification. Prod sends nulls
/// for guild_id / guild_room_message_id when the viewer isn't in a guild; with
/// WhenWritingNull those keys are omitted on our wire, which is equivalent
/// since the parser is null-tolerant.
/// MyPageNotifications.GuildNotification.SetGuildNotification (GuildNotification.cs:30-38),
/// which reads guild_id / guild_room_message_id via `var x = json["guild_id"]; if (x != null) ...`
/// — the LitJson indexer throws KeyNotFoundException on a missing key, so these
/// must reach the client as explicit nulls when there's no guild. Override the
/// global WhenWritingNull so they survive serialization. Prod's wire matches:
/// `"guild_notification":{"guild_id":null,"guild_room_message_id":null,...}`.
/// See [[project-wire-null-policy]] for the broader pattern.
/// </summary>
[MessagePackObject]
public class GuildNotification
{
[JsonPropertyName("guild_id")]
[Key("guild_id")]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public long? GuildId { get; set; }
[JsonPropertyName("guild_room_message_id")]
[Key("guild_room_message_id")]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public long? GuildRoomMessageId { get; set; }
[JsonPropertyName("is_join_request")]

View File

@@ -20,9 +20,10 @@ public class MasterPointRankingPeriod
[Key("period_num")]
public int PeriodNum { get; set; }
/// <summary>Stored as long to mirror MasterPointRankingPeriodEntry.NecessaryScore (rank-point thresholds can grow large).</summary>
[JsonPropertyName("necessary_score")]
[Key("necessary_score")]
public int NecessaryScore { get; set; }
public long NecessaryScore { get; set; }
/// <summary>ISO datetime.</summary>
[JsonPropertyName("begin_time")]

View File

@@ -0,0 +1,102 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
/// <summary>
/// One entry under /payment_pc/item_list data, parsed by PaymentItemListTask
/// (Cute/PaymentItemListTask.cs:43-70). The client iterates via int index and reads
/// 8 fields unconditionally (store_product_id, name, text, purchase_limit, id, image_name,
/// end_time, special_shop_flag), with number_of_product_purchased TryGetValue-guarded.
///
/// All wire fields are PHP-stringified EXCEPT <c>purchase_num_current</c>, which is a true int.
/// String-typed properties avoid JsonConverter machinery — the controller stringifies typed DB
/// columns via ToString(InvariantCulture) on the way out, same approach as MyPageController.BuildBannerInfo.
///
/// Prod-captured shape (one entry):
/// <code>
/// {"record_id":"21","id":"8","store_product_id":"10011",
/// "name":"60-crystal set","text":"Purchase 60 Crystals","price":"0.99",
/// "charge_crystal_num":"60","free_crystal_num":"0","purchase_limit":"999999999",
/// "special_shop_flag":"0","image_name":"thumbnail_crystal",
/// "start_time":"2022-10-05 15:00:00","end_time":"2030-03-01 14:59:59",
/// "remaining_time":"0","is_resale_product":"0","resale_start_date":"","purchase_num_current":0}
/// </code>
/// </summary>
[MessagePackObject]
public class PaymentItemInfo
{
[JsonPropertyName("record_id")]
[Key("record_id")]
public string RecordId { get; set; } = "0";
[JsonPropertyName("id")]
[Key("id")]
public string Id { get; set; } = "0";
[JsonPropertyName("store_product_id")]
[Key("store_product_id")]
public string StoreProductId { get; set; } = "0";
[JsonPropertyName("name")]
[Key("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("text")]
[Key("text")]
public string Text { get; set; } = string.Empty;
/// <summary>Decimal as PHP-stringified value (e.g. "0.99"). Preserves prod's wire convention.</summary>
[JsonPropertyName("price")]
[Key("price")]
public string Price { get; set; } = "0";
[JsonPropertyName("charge_crystal_num")]
[Key("charge_crystal_num")]
public string ChargeCrystalNum { get; set; } = "0";
[JsonPropertyName("free_crystal_num")]
[Key("free_crystal_num")]
public string FreeCrystalNum { get; set; } = "0";
[JsonPropertyName("purchase_limit")]
[Key("purchase_limit")]
public string PurchaseLimit { get; set; } = "0";
[JsonPropertyName("special_shop_flag")]
[Key("special_shop_flag")]
public string SpecialShopFlag { get; set; } = "0";
[JsonPropertyName("image_name")]
[Key("image_name")]
public string ImageName { get; set; } = string.Empty;
/// <summary>"yyyy-MM-dd HH:mm:ss" wire format.</summary>
[JsonPropertyName("start_time")]
[Key("start_time")]
public string StartTime { get; set; } = string.Empty;
/// <summary>"yyyy-MM-dd HH:mm:ss" wire format.</summary>
[JsonPropertyName("end_time")]
[Key("end_time")]
public string EndTime { get; set; } = string.Empty;
[JsonPropertyName("remaining_time")]
[Key("remaining_time")]
public string RemainingTime { get; set; } = "0";
[JsonPropertyName("is_resale_product")]
[Key("is_resale_product")]
public string IsResaleProduct { get; set; } = "0";
/// <summary>Empty string ("") when unset; otherwise "yyyy-MM-dd HH:mm:ss". Matches prod.</summary>
[JsonPropertyName("resale_start_date")]
[Key("resale_start_date")]
public string ResaleStartDate { get; set; } = string.Empty;
/// <summary>True int on the wire (not string) — count of this viewer's purchases of this product.
/// Per-viewer state; currently hardcoded to 0 server-side until purchase tracking lands.</summary>
[JsonPropertyName("purchase_num_current")]
[Key("purchase_num_current")]
public int PurchaseNumCurrent { get; set; }
}

View File

@@ -0,0 +1,14 @@
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
/// <summary>
/// Request body for /mypage/refresh. Carries only the standard auth envelope —
/// no <c>carrier</c> field, unlike MyPageIndexRequest. Confirmed against prod traffic
/// in data_dumps/traffic_prod.ndjson: both refresh request bodies have exactly
/// <c>viewer_id / steam_id / steam_session_ticket</c>.
/// </summary>
[MessagePackObject]
public class MyPageRefreshRequest : BaseRequest
{
}

View File

@@ -0,0 +1,12 @@
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
/// <summary>
/// Request body for /payment_pc/item_list. Prod sends only the standard auth envelope
/// (viewer_id / steam_id / steam_session_ticket) — no additional fields.
/// </summary>
[MessagePackObject]
public class PaymentItemListRequest : BaseRequest
{
}

View File

@@ -16,9 +16,29 @@ public class DeckListResponse
[JsonPropertyName("maintenance_card_list")]
[Key("maintenance_card_list")] public List<long> MaintenanceCardList { get; set; } = new();
/// <summary>
/// Single-format viewer decks. Emitted when the request specified a specific format
/// (e.g. Rotation, Unlimited) — mutually exclusive with the per-format keys below.
/// </summary>
[JsonPropertyName("user_deck_list")]
[Key("user_deck_list")] public List<UserDeck>? UserDeckList { get; set; }
/// <summary>
/// Per-format viewer decks. Emitted when the request specified All format (deck_format=0).
/// Prod's <c>DeckListUtility.ParseDeckInfoResponceData</c> All-format branch only walks these
/// per-format keys (not user_deck_list), so the controller swaps shape based on the request.
/// The PreRotation / Crossover / Avatar siblings exist in client code but prod omits them
/// for fresh viewers; we mirror that omission.
/// </summary>
[JsonPropertyName("user_deck_rotation")]
[Key("user_deck_rotation")] public List<UserDeck>? UserDeckRotation { get; set; }
[JsonPropertyName("user_deck_unlimited")]
[Key("user_deck_unlimited")] public List<UserDeck>? UserDeckUnlimited { get; set; }
[JsonPropertyName("user_deck_my_rotation")]
[Key("user_deck_my_rotation")] public List<UserDeck>? UserDeckMyRotation { get; set; }
/// <summary>
/// Global starter decks, keyed by deck_no as string (prod ids 91-98 — one per class).
/// </summary>

View File

@@ -89,6 +89,48 @@ public class MyPageIndexResponse
[Key("is_available_colosseum_free_entry")]
public bool IsAvailableColosseumFreeEntry { get; set; }
// ── Sealed Arena season ────────────────────────────────────────────────
/// <summary>
/// sealed_info is consumed by ArenaData.SetSealedMyPageResponseData (Keys.Contains-guarded),
/// but post-parse-consumer policy says we emit anyway. Defaults to a zeroed-out SealedInfo
/// when no current season is seeded — Enable=0 means the UI treats Sealed as inactive.
/// </summary>
[JsonPropertyName("sealed_info")]
[Key("sealed_info")]
public SealedInfo SealedInfo { get; set; } = new();
// ── Mypage banner carousel ─────────────────────────────────────────────
/// <summary>
/// banner is consumed by per-entry parsing inside a TryGetValue guard
/// (Wizard/MyPageBannerBase.BannerInfo.Parse iterates the array if present). We always emit
/// the list — empty when no rows have been imported. See SVSim.Bootstrap.GlobalsImporter.ImportBanners.
/// </summary>
[JsonPropertyName("banner")]
[Key("banner")]
public List<BannerInfo> Banner { get; set; } = new();
/// <summary>Prod sends explicit null. Override WhenWritingNull so the key survives serialization.</summary>
[JsonPropertyName("sub_banner")]
[Key("sub_banner")]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public object? SubBanner { get; set; }
[JsonPropertyName("sub_banner_list")]
[Key("sub_banner_list")]
public List<object> SubBannerList { get; set; } = new();
[JsonPropertyName("home_dialog_list")]
[Key("home_dialog_list")]
public List<object> HomeDialogList { get; set; } = new();
// ── Room type in session (Special-format windows) ──────────────────────
[JsonPropertyName("room_type_in_session")]
[Key("room_type_in_session")]
public RoomTypeInSession RoomTypeInSession { get; set; } = new();
/// <summary>
/// Required — ColosseumEntryInfoTask.SetColosseumInfo indexes this key
/// directly (Wizard/ColosseumEntryInfoTask.cs:102) and reads
@@ -104,25 +146,37 @@ public class MyPageIndexResponse
[Key("convention")]
public Convention Convention { get; set; } = new();
// ── Battle / room recovery (optional) ─────────────────────────────────
/// <summary>
/// Required — MyPageTask.cs:110 constructs ArenaCompetition(responseData)
/// which indexes data.competition_info.is_competition_period unconditionally
/// (ArenaCompetition.cs:232-233). When false, the rest of the block is
/// skipped, so a default-constructed CompetitionInfo is sufficient.
/// </summary>
[JsonPropertyName("competition_info")]
[Key("competition_info")]
public CompetitionInfo CompetitionInfo { get; set; } = new();
// ── Battle / room recovery ─────────────────────────────────────────────
/// <summary>Prod always sends concrete bool here even for fresh viewers — emit always.</summary>
[JsonPropertyName("unfinished_battle_exists")]
[Key("unfinished_battle_exists")]
public bool? UnfinishedBattleExists { get; set; }
public bool UnfinishedBattleExists { get; set; }
/// <summary>Only meaningful when UnfinishedBattleExists is true. Keep nullable + omitted otherwise — prod also omits it for fresh viewers.</summary>
[JsonPropertyName("battle_finish_wait_time")]
[Key("battle_finish_wait_time")]
public int? BattleFinishWaitTime { get; set; }
[JsonPropertyName("is_joined_room")]
[Key("is_joined_room")]
public bool? IsJoinedRoom { get; set; }
public bool IsJoinedRoom { get; set; }
// ── Login bonus (optional) ─────────────────────────────────────────────
// ── Login bonus ────────────────────────────────────────────────────────
[JsonPropertyName("can_give_daily_login_bonus")]
[Key("can_give_daily_login_bonus")]
public bool? CanGiveDailyLoginBonus { get; set; }
public bool CanGiveDailyLoginBonus { get; set; }
// ── User config (settings echo) ────────────────────────────────────────
@@ -210,14 +264,45 @@ public class MyPageIndexResponse
[Key("story_notification")]
public StoryNotification StoryNotification { get; set; } = new();
// ── Optional UI surface area ───────────────────────────────────────────
// ── Per-viewer / event state ───────────────────────────────────────────
/// <summary>Updated item counts. Refreshes Data.Load.data._userItemDict when present.</summary>
/// <summary>
/// Updated item counts. Empty list = "no items to update" (client iterates 0 times, no UI change).
/// Per-viewer state — populate from viewer.Items when that wiring lands.
/// </summary>
[JsonPropertyName("user_item_list")]
[Key("user_item_list")]
public List<UserItem>? UserItemList { get; set; }
public List<UserItem> UserItemList { get; set; } = new();
[JsonPropertyName("gathering_info")]
[Key("gathering_info")]
public GatheringInfo? GatheringInfo { get; set; }
public GatheringInfo GatheringInfo { get; set; } = new();
/// <summary>Per-viewer offline-event participation. Empty for fresh viewers; prod also sends [].</summary>
[JsonPropertyName("user_offline_event")]
[Key("user_offline_event")]
public List<object> UserOfflineEvent { get; set; } = new();
// ── Fields prod sends as explicit null ─────────────────────────────────
/// <summary>
/// CRITICAL — emitting this field (even as null) routes MyPageTask.Parse through
/// CampaignBattleWin.Clear() which initializes RewardList = new List&lt;...&gt;(). Without it,
/// RewardList stays null and MyPageMenu.GetMyPageInfo NREs on its foreach iteration.
/// See [[project-wire-null-policy]] for the broader "post-parse-consumer" rationale.
/// </summary>
[JsonPropertyName("treasure_info")]
[Key("treasure_info")]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public object? TreasureInfo { get; set; }
[JsonPropertyName("lottery_period_info")]
[Key("lottery_period_info")]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public object? LotteryPeriodInfo { get; set; }
[JsonPropertyName("all_card_enabled_period")]
[Key("all_card_enabled_period")]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public object? AllCardEnabledPeriod { get; set; }
}

View File

@@ -0,0 +1,39 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses;
/// <summary>
/// /mypage/refresh response — a slim notification-delta payload, NOT a full state refresh.
/// Prod sends exactly 3 top-level keys, all of which the client reads unconditionally:
///
/// <list type="bullet">
/// <item><c>friend_battle_invite_count</c> — int, viewer's room-invite count
/// (consumed at <c>MyPageRefreshTask.cs:29</c>).</item>
/// <item><c>shop_notification</c> — same nested shape as /mypage/index's shop_notification.
/// The side-effect call <c>ShopNotification.SetShopNotification</c> unconditionally indexes
/// all four sub-keys (card_pack / build_deck / sleeve / leader_skin), already handled by
/// our <see cref="ShopNotification"/> DTO's field initializers.</item>
/// <item><c>gathering_notification</c> — new shape distinct from /mypage/index's gathering_info.
/// Carries only the matching-established message string.</item>
/// </list>
///
/// All three fields are required-present per the new "anything prod emits, we emit" methodology
/// — even though the third call site looks tolerant, omitting the key would throw
/// KeyNotFoundException at LitJson's indexer.
/// </summary>
[MessagePackObject]
public class MyPageRefreshResponse
{
[JsonPropertyName("friend_battle_invite_count")]
[Key("friend_battle_invite_count")]
public int FriendBattleInviteCount { get; set; }
[JsonPropertyName("shop_notification")]
[Key("shop_notification")]
public ShopNotification ShopNotification { get; set; } = new();
[JsonPropertyName("gathering_notification")]
[Key("gathering_notification")]
public GatheringNotification GatheringNotification { get; set; } = new();
}

View File

@@ -0,0 +1,20 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
/// <summary>
/// room_type_in_session on /mypage/index — list of "special" deck-format windows currently active.
/// Consumed by RoomRuleInfo (Wizard/RoomRuleInfo.cs:61) via TryGetValue, but emitted unconditionally
/// per the post-parse-consumer-safe policy.
///
/// Prod-captured shape:
/// <code>{"special_deck_format_list": [{"deck_format":"5","end_time":"2030-06-26 19:59:59"}]}</code>
/// </summary>
[MessagePackObject]
public class RoomTypeInSession
{
[JsonPropertyName("special_deck_format_list")]
[Key("special_deck_format_list")]
public List<SpecialDeckFormat> SpecialDeckFormatList { get; set; } = new();
}

View File

@@ -0,0 +1,62 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
/// <summary>
/// sealed_info on /mypage/index — current Sealed Arena season configuration. Consumed by
/// ArenaData.SetSealedMyPageResponseData (ArenaData.cs:59-65), which is Keys.Contains-guarded,
/// but post-parse UI almost certainly dereferences fields from the SealedMyPageResponseData
/// it builds. Since the user reclassified "Safe to omit" as a non-policy, we now always emit.
///
/// Prod-captured shape:
/// <code>
/// {"enable":1,"crystal_cost":600,"rupy_cost":600,"ticket_cost":4,"is_join":false,
/// "pack_info":[10032,10032,10031,10030,10029],"deck_using_num_min":30,"schedule_id":21,
/// "is_deck_code_maintenance":false,"sales_period_info":{"sales_period_series":33}}
/// </code>
/// </summary>
[MessagePackObject]
public class SealedInfo
{
[JsonPropertyName("enable")]
[Key("enable")]
public int Enable { get; set; }
[JsonPropertyName("crystal_cost")]
[Key("crystal_cost")]
public int CrystalCost { get; set; }
[JsonPropertyName("rupy_cost")]
[Key("rupy_cost")]
public int RupyCost { get; set; }
[JsonPropertyName("ticket_cost")]
[Key("ticket_cost")]
public int TicketCost { get; set; }
[JsonPropertyName("is_join")]
[Key("is_join")]
public bool IsJoin { get; set; }
/// <summary>Pack set ids used in this Sealed pool. Prod sends 5 entries (one per draft pack).</summary>
[JsonPropertyName("pack_info")]
[Key("pack_info")]
public List<int> PackInfo { get; set; } = new();
[JsonPropertyName("deck_using_num_min")]
[Key("deck_using_num_min")]
public int DeckUsingNumMin { get; set; }
[JsonPropertyName("schedule_id")]
[Key("schedule_id")]
public int ScheduleId { get; set; }
[JsonPropertyName("is_deck_code_maintenance")]
[Key("is_deck_code_maintenance")]
public bool IsDeckCodeMaintenance { get; set; }
[JsonPropertyName("sales_period_info")]
[Key("sales_period_info")]
public SealedSalesPeriodInfo SalesPeriodInfo { get; set; } = new();
}

View File

@@ -0,0 +1,17 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
/// <summary>
/// Nested under /mypage/index data.sealed_info.sales_period_info. Distinct from Arena/Colosseum's
/// sales_period_info shapes — this inner value is an int (the active schedule series number),
/// not a date string. Captured from prod: <c>"sales_period_info": { "sales_period_series": 33 }</c>.
/// </summary>
[MessagePackObject]
public class SealedSalesPeriodInfo
{
[JsonPropertyName("sales_period_series")]
[Key("sales_period_series")]
public int SalesPeriodSeries { get; set; }
}

View File

@@ -0,0 +1,25 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
/// <summary>
/// One entry under /mypage/index data.room_type_in_session.special_deck_format_list. Consumed by
/// RoomRuleInfo ctor (Wizard/RoomRuleInfo.cs:61-70) which is TryGetValue-guarded but the per-entry
/// fields are accessed unconditionally inside the guard.
///
/// Prod-captured shape: <c>{"deck_format":"5","end_time":"2030-06-26 19:59:59"}</c>.
/// </summary>
[MessagePackObject]
public class SpecialDeckFormat
{
/// <summary>Wire is string per prod's PHP convention (despite looking numeric like "5").</summary>
[JsonPropertyName("deck_format")]
[Key("deck_format")]
public string DeckFormat { get; set; } = string.Empty;
/// <summary>"yyyy-MM-dd HH:mm:ss" wire format.</summary>
[JsonPropertyName("end_time")]
[Key("end_time")]
public string EndTime { get; set; } = string.Empty;
}