Deck list work

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

View File

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

View File

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

View File

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

View File

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