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? loadIndex = LoadCapture(capturesDir, "load-index");
|
||||||
JsonElement? mypageIndex = LoadCapture(capturesDir, "mypage-index");
|
JsonElement? mypageIndex = LoadCapture(capturesDir, "mypage-index");
|
||||||
JsonElement? deckInfo = LoadCapture(capturesDir, "deck-info");
|
JsonElement? deckInfo = LoadCapture(capturesDir, "deck-info");
|
||||||
|
JsonElement? paymentItemList = LoadCapture(capturesDir, "payment-item-list");
|
||||||
|
|
||||||
int total = 0;
|
int total = 0;
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ public class GlobalsImporter
|
|||||||
total += await ImportColosseum(context, mypageIndex.Value);
|
total += await ImportColosseum(context, mypageIndex.Value);
|
||||||
total += await ImportSealed(context, mypageIndex.Value);
|
total += await ImportSealed(context, mypageIndex.Value);
|
||||||
total += await ImportMasterPointRankingPeriod(context, mypageIndex.Value);
|
total += await ImportMasterPointRankingPeriod(context, mypageIndex.Value);
|
||||||
|
total += await ImportRoomTypeInSession(context, mypageIndex.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deckInfo.HasValue)
|
if (deckInfo.HasValue)
|
||||||
@@ -62,6 +64,11 @@ public class GlobalsImporter
|
|||||||
total += await ImportDefaultLeaderSkinSettings(context, deckInfo.Value);
|
total += await ImportDefaultLeaderSkinSettings(context, deckInfo.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (paymentItemList.HasValue)
|
||||||
|
{
|
||||||
|
total += await ImportPaymentItems(context, paymentItemList.Value);
|
||||||
|
}
|
||||||
|
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
Console.WriteLine($"[GlobalsImporter] Done: {total} total rows changed.");
|
Console.WriteLine($"[GlobalsImporter] Done: {total} total rows changed.");
|
||||||
return total;
|
return total;
|
||||||
@@ -550,6 +557,34 @@ public class GlobalsImporter
|
|||||||
return 1;
|
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 ----------
|
// ---------- Deck/info: Default Decks ----------
|
||||||
|
|
||||||
private async Task<int> ImportDefaultDecks(SVSimDbContext context, JsonElement deckInfo)
|
private async Task<int> ImportDefaultDecks(SVSimDbContext context, JsonElement deckInfo)
|
||||||
@@ -611,6 +646,54 @@ public class GlobalsImporter
|
|||||||
return created + updated;
|
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 ----------
|
// ---------- Helpers ----------
|
||||||
|
|
||||||
private static void WarnOrphans(string label, int count)
|
private static void WarnOrphans(string label, int count)
|
||||||
|
|||||||
@@ -13,6 +13,22 @@
|
|||||||
<Content Include="Data\prod-captures\*.json">
|
<Content Include="Data\prod-captures\*.json">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</Content>
|
</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>
|
||||||
|
|
||||||
<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");
|
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 =>
|
modelBuilder.Entity("SVSim.Database.Models.PreReleaseInfo", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
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 =>
|
modelBuilder.Entity("SVSim.Database.Models.SpotCardEntry", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
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>();
|
?? 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)
|
public async Task<ShadowverseDeckEntry?> GetDeck(long viewerId, Format format, int deckNo)
|
||||||
{
|
{
|
||||||
var viewer = await _dbContext.Viewers
|
var viewer = await _dbContext.Viewers
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ namespace SVSim.Database.Repositories.Deck;
|
|||||||
public interface IDeckRepository
|
public interface IDeckRepository
|
||||||
{
|
{
|
||||||
Task<List<ShadowverseDeckEntry>> GetDecks(long viewerId, Format format);
|
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<ShadowverseDeckEntry?> GetDeck(long viewerId, Format format, int deckNo);
|
||||||
Task<int> GetEmptyDeckNumber(long viewerId, Format format);
|
Task<int> GetEmptyDeckNumber(long viewerId, Format format);
|
||||||
Task<ShadowverseDeckEntry> UpsertDeck(long viewerId, Format format, int deckNo, Action<ShadowverseDeckEntry> mutate);
|
Task<ShadowverseDeckEntry> UpsertDeck(long viewerId, Format format, int deckNo, Action<ShadowverseDeckEntry> mutate);
|
||||||
|
|||||||
@@ -94,6 +94,12 @@ public class GlobalsRepository : IGlobalsRepository
|
|||||||
.FirstOrDefaultAsync();
|
.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() =>
|
public Task<List<MaintenanceCardEntry>> GetMaintenanceCards() =>
|
||||||
_dbContext.MaintenanceCards.AsNoTracking().ToListAsync();
|
_dbContext.MaintenanceCards.AsNoTracking().ToListAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ public interface IGlobalsRepository
|
|||||||
Task<ColosseumConfig?> GetCurrentColosseum();
|
Task<ColosseumConfig?> GetCurrentColosseum();
|
||||||
Task<SealedConfig?> GetCurrentSealedSeason();
|
Task<SealedConfig?> GetCurrentSealedSeason();
|
||||||
Task<MasterPointRankingPeriodEntry?> GetCurrentMasterPointPeriod();
|
Task<MasterPointRankingPeriodEntry?> GetCurrentMasterPointPeriod();
|
||||||
|
Task<List<SpecialDeckFormatEntry>> GetActiveSpecialDeckFormats();
|
||||||
|
Task<List<PaymentItemEntry>> GetPaymentItems();
|
||||||
Task<List<MaintenanceCardEntry>> GetMaintenanceCards();
|
Task<List<MaintenanceCardEntry>> GetMaintenanceCards();
|
||||||
Task<List<FeatureMaintenanceEntry>> GetFeatureMaintenances();
|
Task<List<FeatureMaintenanceEntry>> GetFeatureMaintenances();
|
||||||
Task<PreReleaseInfo?> GetPreReleaseInfo();
|
Task<PreReleaseInfo?> GetPreReleaseInfo();
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ public class SVSimDbContext : DbContext
|
|||||||
public DbSet<ColosseumConfig> Colosseums => Set<ColosseumConfig>();
|
public DbSet<ColosseumConfig> Colosseums => Set<ColosseumConfig>();
|
||||||
public DbSet<SealedConfig> SealedSeasons => Set<SealedConfig>();
|
public DbSet<SealedConfig> SealedSeasons => Set<SealedConfig>();
|
||||||
public DbSet<MasterPointRankingPeriodEntry> MasterPointRankingPeriods => Set<MasterPointRankingPeriodEntry>();
|
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<MaintenanceCardEntry> MaintenanceCards => Set<MaintenanceCardEntry>();
|
||||||
public DbSet<FeatureMaintenanceEntry> FeatureMaintenances => Set<FeatureMaintenanceEntry>();
|
public DbSet<FeatureMaintenanceEntry> FeatureMaintenances => Set<FeatureMaintenanceEntry>();
|
||||||
public DbSet<PreReleaseInfo> PreReleaseInfos => Set<PreReleaseInfo>();
|
public DbSet<PreReleaseInfo> PreReleaseInfos => Set<PreReleaseInfo>();
|
||||||
|
|||||||
@@ -44,15 +44,34 @@ public class DeckController : SVSimController
|
|||||||
public async Task<ActionResult<DeckListResponse>> Info(DeckInfoRequest request)
|
public async Task<ActionResult<DeckListResponse>> Info(DeckInfoRequest request)
|
||||||
{
|
{
|
||||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
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 defaultDecks = await _globalsRepository.GetDefaultDecks();
|
||||||
var leaderSkinSettings = await _globalsRepository.GetDefaultLeaderSkinSettings();
|
var leaderSkinSettings = await _globalsRepository.GetDefaultLeaderSkinSettings();
|
||||||
|
|
||||||
return new DeckListResponse
|
var response = new DeckListResponse
|
||||||
{
|
{
|
||||||
UserDeckList = decks.Select(d => new UserDeck(d)).ToList(),
|
|
||||||
DefaultDeckList = defaultDecks.ToDictionary(
|
DefaultDeckList = defaultDecks.ToDictionary(
|
||||||
d => d.Id.ToString(),
|
d => d.Id.ToString(),
|
||||||
d => new DefaultDeck
|
d => new DefaultDeck
|
||||||
@@ -63,6 +82,11 @@ public class DeckController : SVSimController
|
|||||||
LeaderSkinId = d.LeaderSkinId,
|
LeaderSkinId = d.LeaderSkinId,
|
||||||
DeckName = d.DeckName,
|
DeckName = d.DeckName,
|
||||||
CardIdArray = System.Text.Json.JsonSerializer.Deserialize<List<long>>(d.CardIdArray, JsonbReadOptions) ?? new(),
|
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(
|
UserLeaderSkinSettingList = leaderSkinSettings.ToDictionary(
|
||||||
s => s.Id.ToString(),
|
s => s.Id.ToString(),
|
||||||
@@ -77,17 +101,25 @@ public class DeckController : SVSimController
|
|||||||
TrialDeckList = new(),
|
TrialDeckList = new(),
|
||||||
MaintenanceCardList = new(), // sourced from same place as /load/index when wired
|
MaintenanceCardList = new(), // sourced from same place as /load/index when wired
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("my_list")]
|
if (requestFormat == Format.All)
|
||||||
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
|
|
||||||
{
|
{
|
||||||
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")]
|
[HttpPost("get_empty_deck_number")]
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using SVSim.Database.Enums;
|
using SVSim.Database.Enums;
|
||||||
using SVSim.Database.Models;
|
using SVSim.Database.Models;
|
||||||
@@ -10,6 +9,7 @@ using SVSim.Database.Repositories.Collectibles;
|
|||||||
using SVSim.Database.Repositories.Globals;
|
using SVSim.Database.Repositories.Globals;
|
||||||
using SVSim.Database.Repositories.Viewer;
|
using SVSim.Database.Repositories.Viewer;
|
||||||
using SVSim.EmulatedEntrypoint.Constants;
|
using SVSim.EmulatedEntrypoint.Constants;
|
||||||
|
using SVSim.EmulatedEntrypoint.Infrastructure;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses;
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses;
|
||||||
@@ -37,19 +37,6 @@ public class LoadController : SVSimController
|
|||||||
new CardSetIdentifier { SetId = 10010 }
|
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 IViewerRepository _viewerRepository;
|
||||||
private readonly ICardRepository _cardRepository;
|
private readonly ICardRepository _cardRepository;
|
||||||
private readonly ICollectionRepository _collectionRepository;
|
private readonly ICollectionRepository _collectionRepository;
|
||||||
@@ -257,7 +244,7 @@ public class LoadController : SVSimController
|
|||||||
ArenaFormatInfo? format = null;
|
ArenaFormatInfo? format = null;
|
||||||
if (!string.IsNullOrEmpty(season.FormatInfo) && season.FormatInfo != "{}")
|
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>
|
return new List<ArenaInfo>
|
||||||
@@ -298,13 +285,13 @@ public class LoadController : SVSimController
|
|||||||
}),
|
}),
|
||||||
Abilities = abilities.ToDictionary(
|
Abilities = abilities.ToDictionary(
|
||||||
a => a.Id.ToString(),
|
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(
|
ReprintedCards = settings.ToDictionary(
|
||||||
s => s.Id.ToString(),
|
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(
|
Banlist = settings.ToDictionary(
|
||||||
s => s.Id.ToString(),
|
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
|
DisabledCardSets = new List<int>(), // prod 2026-05-23 emits empty list; refine if/when populated
|
||||||
Schedules = BuildMyRotationSchedules(),
|
Schedules = BuildMyRotationSchedules(),
|
||||||
};
|
};
|
||||||
@@ -369,9 +356,9 @@ public class LoadController : SVSimController
|
|||||||
PreReleaseCardMasterId = pri.PreReleaseCardMasterId,
|
PreReleaseCardMasterId = pri.PreReleaseCardMasterId,
|
||||||
FreeMatchStartTime = pri.FreeMatchStartTime,
|
FreeMatchStartTime = pri.FreeMatchStartTime,
|
||||||
CardMasterId = pri.CardMasterId,
|
CardMasterId = pri.CardMasterId,
|
||||||
RotationCardSets = JsonSerializer.Deserialize<List<int>>(pri.RotationCardSetIdList, JsonbReadOptions) ?? new(),
|
RotationCardSets = JsonSerializer.Deserialize<List<int>>(pri.RotationCardSetIdList, JsonbReadOptions.Instance) ?? new(),
|
||||||
ReprintedCardIds = JsonSerializer.Deserialize<Dictionary<string, string>>(pri.ReprintedBaseCardIds, JsonbReadOptions) ?? new(),
|
ReprintedCardIds = JsonSerializer.Deserialize<Dictionary<string, string>>(pri.ReprintedBaseCardIds, JsonbReadOptions.Instance) ?? new(),
|
||||||
LatestReprintedCardIds = JsonSerializer.Deserialize<List<int>>(pri.LatestReprintedBaseCardIds, JsonbReadOptions) ?? new(),
|
LatestReprintedCardIds = JsonSerializer.Deserialize<List<int>>(pri.LatestReprintedBaseCardIds, JsonbReadOptions.Instance) ?? new(),
|
||||||
IsPreRotationFreeMatchTerm = pri.IsPreRotationFreeMatchTerm ? 1 : 0,
|
IsPreRotationFreeMatchTerm = pri.IsPreRotationFreeMatchTerm ? 1 : 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using SVSim.Database.Models;
|
using SVSim.Database.Models;
|
||||||
using SVSim.Database.Repositories.Globals;
|
using SVSim.Database.Repositories.Globals;
|
||||||
using SVSim.Database.Repositories.Viewer;
|
using SVSim.Database.Repositories.Viewer;
|
||||||
using SVSim.EmulatedEntrypoint.Constants;
|
using SVSim.EmulatedEntrypoint.Constants;
|
||||||
|
using SVSim.EmulatedEntrypoint.Infrastructure;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses;
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses;
|
||||||
@@ -11,6 +14,10 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
|
|||||||
|
|
||||||
public class MyPageController : SVSimController
|
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 IViewerRepository _viewerRepository;
|
||||||
private readonly IGlobalsRepository _globalsRepository;
|
private readonly IGlobalsRepository _globalsRepository;
|
||||||
|
|
||||||
@@ -38,9 +45,15 @@ public class MyPageController : SVSimController
|
|||||||
var deviceHeader = Request.Headers["DEVICE"].FirstOrDefault();
|
var deviceHeader = Request.Headers["DEVICE"].FirstOrDefault();
|
||||||
int deviceType = int.TryParse(deviceHeader, out int parsed) ? parsed : 0;
|
int deviceType = int.TryParse(deviceHeader, out int parsed) ? parsed : 0;
|
||||||
|
|
||||||
// Stubs below are tagged TODO(mypage-stub). See the "Current server implementation"
|
// Hydrate all the globals slices in parallel-ish — they're independent reads.
|
||||||
// section of docs/api-spec/endpoints/post-login/mypage-index.md for the table of what
|
var cfg = await _globalsRepository.GetGameConfiguration("default");
|
||||||
// each one would source from. Grep for "mypage-stub" to enumerate them.
|
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
|
return new MyPageIndexResponse
|
||||||
{
|
{
|
||||||
UserInfo = new UserInfo(deviceType, viewer),
|
UserInfo = new UserInfo(deviceType, viewer),
|
||||||
@@ -55,6 +68,19 @@ public class MyPageController : SVSimController
|
|||||||
ArenaInfo = await BuildArenaInfosAsync(),
|
ArenaInfo = await BuildArenaInfosAsync(),
|
||||||
IsArenaChallengePeriod = false, // TODO(mypage-stub): globals/ArenaSeason flag
|
IsArenaChallengePeriod = false, // TODO(mypage-stub): globals/ArenaSeason flag
|
||||||
IsAvailableColosseumFreeEntry = false, // TODO(mypage-stub): viewer + globals free-entry quota
|
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
|
Convention = new Convention // TODO(mypage-stub): viewer offline-event participation
|
||||||
{
|
{
|
||||||
IsJoinTournament = false,
|
IsJoinTournament = false,
|
||||||
@@ -62,36 +88,61 @@ public class MyPageController : SVSimController
|
|||||||
},
|
},
|
||||||
UserConfig = new UserConfig(), // TODO(mypage-stub): persist viewer UserConfig
|
UserConfig = new UserConfig(), // TODO(mypage-stub): persist viewer UserConfig
|
||||||
Quest = new Quest(), // TODO(mypage-stub): active Quest event + viewer flags
|
Quest = new Quest(), // TODO(mypage-stub): active Quest event + viewer flags
|
||||||
MasterPointRankingPeriod = new MasterPointRankingPeriod
|
MasterPointRankingPeriod = BuildMasterPointRankingPeriod(masterPointPeriod),
|
||||||
{
|
|
||||||
// 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",
|
|
||||||
},
|
|
||||||
PreReleaseStatus = 0, // TODO(mypage-stub): derive from PreReleaseInfo
|
PreReleaseStatus = 0, // TODO(mypage-stub): derive from PreReleaseInfo
|
||||||
UserMyPageInfo = new UserMyPageInfo // TODO(mypage-stub): viewer mypage BG selection
|
UserMyPageInfo = new UserMyPageInfo // TODO(mypage-stub): viewer mypage BG selection
|
||||||
{
|
{
|
||||||
UserMyPageSetting = new MyPageBgSetting(),
|
UserMyPageSetting = new MyPageBgSetting(),
|
||||||
},
|
},
|
||||||
BasicPuzzle = new BasicPuzzle { IsDisplayBadge = false }, // TODO(mypage-stub): viewer practice-puzzle progress
|
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
|
SpecialCrystalInfo = new(), // TODO(mypage-stub): same shape/source as /load/index
|
||||||
// ColosseumInfo, ShopNotification, StoryNotification, IsHiddenBossAppeared all
|
// CompetitionInfo, ShopNotification, StoryNotification, GuildNotification, GatheringInfo,
|
||||||
// default-constructed by MyPageIndexResponse's field initializers.
|
// IsHiddenBossAppeared, SubBanner/SubBannerList/HomeDialogList/UserOfflineEvent/UserItemList,
|
||||||
// TODO(mypage-stub): wire colosseum_info from current Colosseum cup row.
|
// 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 shop_notification from per-product shop-appeal state.
|
||||||
// TODO(mypage-stub): wire story_notification from viewer story progress.
|
// 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): 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>
|
/// <summary>
|
||||||
/// Same shape as LoadController.BuildArenaInfosAsync, but /mypage/index has no
|
/// Slim notification-delta endpoint — see MyPageRefreshResponse for the 3-field contract.
|
||||||
/// Keys.Contains("arena_info") guard on the client (ArenaData(jsonData["arena_info"])
|
/// Client fires this once after main-menu UI settles (and a second time shortly after; both
|
||||||
/// at MyPageTask.cs:55 indexes [0] unconditionally). When no current Take Two season is
|
/// calls get the same response). No state changes happen here; everything is read-only.
|
||||||
/// seeded we fall back to a minimal one-entry list so the client's ArenaData ctor doesn't
|
/// </summary>
|
||||||
/// crash with IndexOutOfRange.
|
[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>
|
/// </summary>
|
||||||
private async Task<List<ArenaInfo>> BuildArenaInfosAsync()
|
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>
|
return new List<ArenaInfo>
|
||||||
{
|
{
|
||||||
new ArenaInfo
|
new ArenaInfo
|
||||||
@@ -122,10 +179,115 @@ public class MyPageController : SVSimController
|
|||||||
RupeeCost = season.RupyCost,
|
RupeeCost = season.RupyCost,
|
||||||
TicketCost = season.TicketCost,
|
TicketCost = season.TicketCost,
|
||||||
IsJoin = season.IsJoin,
|
IsJoin = season.IsJoin,
|
||||||
// format_info is intentionally omitted here — /mypage/index's ArenaData
|
FormatInfo = format,
|
||||||
// ctor only needs the top-level fields. /load/index round-trips it via
|
|
||||||
// JsonbReadOptions; pull it in if a downstream check ever needs it.
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// colosseum_info on /mypage/index, consumed by
|
/// colosseum_info on /mypage/index, consumed by ColosseumEntryInfoTask.SetColosseumInfo
|
||||||
/// ColosseumEntryInfoTask.SetColosseumInfo (Wizard/ColosseumEntryInfoTask.cs:99).
|
/// (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
|
/// Prod-captured shape (15 fields):
|
||||||
/// `is_colosseum_period` MUST be set. All other fields are only read inside the
|
/// <code>
|
||||||
/// `if (IsColosseumPeriod)` branch, so when no Take Two cup is active we emit
|
/// {"colosseum_id":"165","is_display_tips":"0","tips_id":"0",
|
||||||
/// the minimum payload (is_colosseum_period=false) and leave the rest defaulted.
|
/// "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>
|
/// </summary>
|
||||||
[MessagePackObject]
|
[MessagePackObject]
|
||||||
public class ColosseumInfo
|
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")]
|
[JsonPropertyName("is_colosseum_period")]
|
||||||
[Key("is_colosseum_period")]
|
[Key("is_colosseum_period")]
|
||||||
public bool IsColosseumPeriod { get; set; }
|
public bool IsColosseumPeriod { get; set; }
|
||||||
@@ -23,11 +50,15 @@ public class ColosseumInfo
|
|||||||
[Key("is_round_period")]
|
[Key("is_round_period")]
|
||||||
public bool IsRoundPeriod { get; set; }
|
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")]
|
[JsonPropertyName("deck_format")]
|
||||||
[Key("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")]
|
[JsonPropertyName("is_normal_two_pick")]
|
||||||
[Key("is_normal_two_pick")]
|
[Key("is_normal_two_pick")]
|
||||||
public string IsNormalTwoPick { get; set; } = "0";
|
public string IsNormalTwoPick { get; set; } = "0";
|
||||||
@@ -37,7 +68,30 @@ public class ColosseumInfo
|
|||||||
[Key("is_special_mode")]
|
[Key("is_special_mode")]
|
||||||
public string IsSpecialMode { get; set; } = "0";
|
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")]
|
[JsonPropertyName("colosseum_name")]
|
||||||
[Key("colosseum_name")]
|
[Key("colosseum_name")]
|
||||||
public string ColosseumName { get; set; } = string.Empty;
|
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; }
|
public bool IsJoinTournament { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// ISO datetime. Optional — omitted via WhenWritingNull when not set.
|
/// ISO datetime, or null when no recent tournament. Client does
|
||||||
/// Client null-checks before parsing (MyPageTask.cs:59).
|
/// `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>
|
/// </summary>
|
||||||
[JsonPropertyName("recent_start_date")]
|
[JsonPropertyName("recent_start_date")]
|
||||||
[Key("recent_start_date")]
|
[Key("recent_start_date")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
|
||||||
public string? RecentStartDate { get; set; }
|
public string? RecentStartDate { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("is_admin_watch_user")]
|
[JsonPropertyName("is_admin_watch_user")]
|
||||||
|
|||||||
@@ -35,4 +35,19 @@ public class DefaultDeck
|
|||||||
[JsonPropertyName("card_id_array")]
|
[JsonPropertyName("card_id_array")]
|
||||||
[Key("card_id_array")]
|
[Key("card_id_array")]
|
||||||
public List<long> CardIdArray { get; set; } = new();
|
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;
|
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]
|
[MessagePackObject]
|
||||||
public class GatheringInfo
|
public class GatheringInfo
|
||||||
{
|
{
|
||||||
[JsonPropertyName("has_invite")]
|
[JsonPropertyName("has_invite")]
|
||||||
[Key("has_invite")]
|
[Key("has_invite")]
|
||||||
public int HasInvite { get; set; }
|
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>
|
/// <summary>
|
||||||
/// guild_notification on /mypage/index. Consumed by
|
/// guild_notification on /mypage/index. Consumed by
|
||||||
/// MyPageNotifications.GuildNotification.SetGuildNotification. Prod sends nulls
|
/// MyPageNotifications.GuildNotification.SetGuildNotification (GuildNotification.cs:30-38),
|
||||||
/// for guild_id / guild_room_message_id when the viewer isn't in a guild; with
|
/// which reads guild_id / guild_room_message_id via `var x = json["guild_id"]; if (x != null) ...`
|
||||||
/// WhenWritingNull those keys are omitted on our wire, which is equivalent
|
/// — the LitJson indexer throws KeyNotFoundException on a missing key, so these
|
||||||
/// since the parser is null-tolerant.
|
/// 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>
|
/// </summary>
|
||||||
[MessagePackObject]
|
[MessagePackObject]
|
||||||
public class GuildNotification
|
public class GuildNotification
|
||||||
{
|
{
|
||||||
[JsonPropertyName("guild_id")]
|
[JsonPropertyName("guild_id")]
|
||||||
[Key("guild_id")]
|
[Key("guild_id")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
|
||||||
public long? GuildId { get; set; }
|
public long? GuildId { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("guild_room_message_id")]
|
[JsonPropertyName("guild_room_message_id")]
|
||||||
[Key("guild_room_message_id")]
|
[Key("guild_room_message_id")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
|
||||||
public long? GuildRoomMessageId { get; set; }
|
public long? GuildRoomMessageId { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("is_join_request")]
|
[JsonPropertyName("is_join_request")]
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ public class MasterPointRankingPeriod
|
|||||||
[Key("period_num")]
|
[Key("period_num")]
|
||||||
public int PeriodNum { get; set; }
|
public int PeriodNum { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Stored as long to mirror MasterPointRankingPeriodEntry.NecessaryScore (rank-point thresholds can grow large).</summary>
|
||||||
[JsonPropertyName("necessary_score")]
|
[JsonPropertyName("necessary_score")]
|
||||||
[Key("necessary_score")]
|
[Key("necessary_score")]
|
||||||
public int NecessaryScore { get; set; }
|
public long NecessaryScore { get; set; }
|
||||||
|
|
||||||
/// <summary>ISO datetime.</summary>
|
/// <summary>ISO datetime.</summary>
|
||||||
[JsonPropertyName("begin_time")]
|
[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")]
|
[JsonPropertyName("maintenance_card_list")]
|
||||||
[Key("maintenance_card_list")] public List<long> MaintenanceCardList { get; set; } = new();
|
[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")]
|
[JsonPropertyName("user_deck_list")]
|
||||||
[Key("user_deck_list")] public List<UserDeck>? UserDeckList { get; set; }
|
[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>
|
/// <summary>
|
||||||
/// Global starter decks, keyed by deck_no as string (prod ids 91-98 — one per class).
|
/// Global starter decks, keyed by deck_no as string (prod ids 91-98 — one per class).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -89,6 +89,48 @@ public class MyPageIndexResponse
|
|||||||
[Key("is_available_colosseum_free_entry")]
|
[Key("is_available_colosseum_free_entry")]
|
||||||
public bool IsAvailableColosseumFreeEntry { get; set; }
|
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>
|
/// <summary>
|
||||||
/// Required — ColosseumEntryInfoTask.SetColosseumInfo indexes this key
|
/// Required — ColosseumEntryInfoTask.SetColosseumInfo indexes this key
|
||||||
/// directly (Wizard/ColosseumEntryInfoTask.cs:102) and reads
|
/// directly (Wizard/ColosseumEntryInfoTask.cs:102) and reads
|
||||||
@@ -104,25 +146,37 @@ public class MyPageIndexResponse
|
|||||||
[Key("convention")]
|
[Key("convention")]
|
||||||
public Convention Convention { get; set; } = new();
|
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")]
|
[JsonPropertyName("unfinished_battle_exists")]
|
||||||
[Key("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")]
|
[JsonPropertyName("battle_finish_wait_time")]
|
||||||
[Key("battle_finish_wait_time")]
|
[Key("battle_finish_wait_time")]
|
||||||
public int? BattleFinishWaitTime { get; set; }
|
public int? BattleFinishWaitTime { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("is_joined_room")]
|
[JsonPropertyName("is_joined_room")]
|
||||||
[Key("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")]
|
[JsonPropertyName("can_give_daily_login_bonus")]
|
||||||
[Key("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) ────────────────────────────────────────
|
// ── User config (settings echo) ────────────────────────────────────────
|
||||||
|
|
||||||
@@ -210,14 +264,45 @@ public class MyPageIndexResponse
|
|||||||
[Key("story_notification")]
|
[Key("story_notification")]
|
||||||
public StoryNotification StoryNotification { get; set; } = new();
|
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")]
|
[JsonPropertyName("user_item_list")]
|
||||||
[Key("user_item_list")]
|
[Key("user_item_list")]
|
||||||
public List<UserItem>? UserItemList { get; set; }
|
public List<UserItem> UserItemList { get; set; } = new();
|
||||||
|
|
||||||
[JsonPropertyName("gathering_info")]
|
[JsonPropertyName("gathering_info")]
|
||||||
[Key("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