Deck list work
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
23
SVSim.Bootstrap/DesignTimeDbContextFactory.cs
Normal file
23
SVSim.Bootstrap/DesignTimeDbContextFactory.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
34529
SVSim.Database/Migrations/20260523225845_MypageRoomTypeInSession.Designer.cs
generated
Normal file
34529
SVSim.Database/Migrations/20260523225845_MypageRoomTypeInSession.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
34593
SVSim.Database/Migrations/20260523232258_MypagePaymentItems.Designer.cs
generated
Normal file
34593
SVSim.Database/Migrations/20260523232258_MypagePaymentItems.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
51
SVSim.Database/Models/PaymentItemEntry.cs
Normal file
51
SVSim.Database/Models/PaymentItemEntry.cs
Normal 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; }
|
||||
}
|
||||
17
SVSim.Database/Models/SpecialDeckFormatEntry.cs
Normal file
17
SVSim.Database/Models/SpecialDeckFormatEntry.cs
Normal 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; }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
70
SVSim.EmulatedEntrypoint/Controllers/PaymentController.cs
Normal file
70
SVSim.EmulatedEntrypoint/Controllers/PaymentController.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
26
SVSim.EmulatedEntrypoint/Infrastructure/JsonbReadOptions.cs
Normal file
26
SVSim.EmulatedEntrypoint/Infrastructure/JsonbReadOptions.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
46
SVSim.EmulatedEntrypoint/Models/Dtos/BannerInfo.cs
Normal file
46
SVSim.EmulatedEntrypoint/Models/Dtos/BannerInfo.cs
Normal 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();
|
||||
}
|
||||
@@ -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 (Dragonblade–Rivenbrandt)",
|
||||
/// "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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
23
SVSim.EmulatedEntrypoint/Models/Dtos/CompetitionInfo.cs
Normal file
23
SVSim.EmulatedEntrypoint/Models/Dtos/CompetitionInfo.cs
Normal 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; }
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
@@ -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")]
|
||||
|
||||
102
SVSim.EmulatedEntrypoint/Models/Dtos/PaymentItemInfo.cs
Normal file
102
SVSim.EmulatedEntrypoint/Models/Dtos/PaymentItemInfo.cs
Normal 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; }
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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<...>(). 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; }
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
20
SVSim.EmulatedEntrypoint/Models/Dtos/RoomTypeInSession.cs
Normal file
20
SVSim.EmulatedEntrypoint/Models/Dtos/RoomTypeInSession.cs
Normal 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();
|
||||
}
|
||||
62
SVSim.EmulatedEntrypoint/Models/Dtos/SealedInfo.cs
Normal file
62
SVSim.EmulatedEntrypoint/Models/Dtos/SealedInfo.cs
Normal 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();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
25
SVSim.EmulatedEntrypoint/Models/Dtos/SpecialDeckFormat.cs
Normal file
25
SVSim.EmulatedEntrypoint/Models/Dtos/SpecialDeckFormat.cs
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user