Deck list work
This commit is contained in:
@@ -44,15 +44,34 @@ public class DeckController : SVSimController
|
||||
public async Task<ActionResult<DeckListResponse>> Info(DeckInfoRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
var decks = await _deckRepository.GetDecks(viewerId, AsFormat(request.DeckFormat));
|
||||
return await BuildDeckListResponseAsync(viewerId, AsFormat(request.DeckFormat));
|
||||
}
|
||||
|
||||
// Globals — same shape every call; could be cached if it becomes a hotspot.
|
||||
[HttpPost("my_list")]
|
||||
public async Task<ActionResult<DeckListResponse>> MyList(DeckFormatRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
return await BuildDeckListResponseAsync(viewerId, AsFormat(request.DeckFormat));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared hydration for <c>/deck/info</c> and <c>/deck/my_list</c> — both endpoints return the
|
||||
/// same <see cref="DeckListResponse"/> DTO and the client's DeckInfoTask.Parse / DeckMyListTask.Parse
|
||||
/// are identical (both call <c>DeckGroupListData(jsonData, format)</c>).
|
||||
///
|
||||
/// Wire shape swaps based on the request format. When the client asks for All-format
|
||||
/// (<c>deck_format=0</c>), prod emits per-format keys (<c>user_deck_rotation</c>, etc.);
|
||||
/// for a specific format request, prod emits a single <c>user_deck_list</c>. The client's
|
||||
/// <c>DeckListUtility.ParseDeckInfoResponceData</c> branches on these two shapes, so the
|
||||
/// controller mirrors it exactly.
|
||||
/// </summary>
|
||||
private async Task<DeckListResponse> BuildDeckListResponseAsync(long viewerId, Format requestFormat)
|
||||
{
|
||||
var defaultDecks = await _globalsRepository.GetDefaultDecks();
|
||||
var leaderSkinSettings = await _globalsRepository.GetDefaultLeaderSkinSettings();
|
||||
|
||||
return new DeckListResponse
|
||||
var response = new DeckListResponse
|
||||
{
|
||||
UserDeckList = decks.Select(d => new UserDeck(d)).ToList(),
|
||||
DefaultDeckList = defaultDecks.ToDictionary(
|
||||
d => d.Id.ToString(),
|
||||
d => new DefaultDeck
|
||||
@@ -63,6 +82,11 @@ public class DeckController : SVSimController
|
||||
LeaderSkinId = d.LeaderSkinId,
|
||||
DeckName = d.DeckName,
|
||||
CardIdArray = System.Text.Json.JsonSerializer.Deserialize<List<long>>(d.CardIdArray, JsonbReadOptions) ?? new(),
|
||||
// TODO(deck-stub): wire from real per-deck state once user maintenance / availability tracking lands.
|
||||
// Prod emits is_complete_deck=1, is_available_deck=1, maintenance_card_ids=[] for the 8 starter decks.
|
||||
IsCompleteDeck = 1,
|
||||
IsAvailableDeck = 1,
|
||||
MaintenanceCardIds = new(),
|
||||
}),
|
||||
UserLeaderSkinSettingList = leaderSkinSettings.ToDictionary(
|
||||
s => s.Id.ToString(),
|
||||
@@ -77,17 +101,25 @@ public class DeckController : SVSimController
|
||||
TrialDeckList = new(),
|
||||
MaintenanceCardList = new(), // sourced from same place as /load/index when wired
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("my_list")]
|
||||
public async Task<ActionResult<DeckListResponse>> MyList(DeckFormatRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
var decks = await _deckRepository.GetDecks(viewerId, AsFormat(request.DeckFormat));
|
||||
return new DeckListResponse
|
||||
if (requestFormat == Format.All)
|
||||
{
|
||||
UserDeckList = decks.Select(d => new UserDeck(d)).ToList()
|
||||
};
|
||||
// Prod's All-format response emits these three per-format lists (each [] for fresh viewers).
|
||||
// The PreRotation / Crossover / Avatar siblings exist in client code but prod omits them
|
||||
// for our profile; we mirror that omission and leave the nullable DTO fields unset.
|
||||
var formats = new[] { Format.Rotation, Format.Unlimited, Format.MyRotation };
|
||||
var byFormat = await _deckRepository.GetDecksByFormats(viewerId, formats);
|
||||
response.UserDeckRotation = byFormat[Format.Rotation].Select(d => new UserDeck(d)).ToList();
|
||||
response.UserDeckUnlimited = byFormat[Format.Unlimited].Select(d => new UserDeck(d)).ToList();
|
||||
response.UserDeckMyRotation = byFormat[Format.MyRotation].Select(d => new UserDeck(d)).ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
var decks = await _deckRepository.GetDecks(viewerId, requestFormat);
|
||||
response.UserDeckList = decks.Select(d => new UserDeck(d)).ToList();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
[HttpPost("get_empty_deck_number")]
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
@@ -10,6 +9,7 @@ using SVSim.Database.Repositories.Collectibles;
|
||||
using SVSim.Database.Repositories.Globals;
|
||||
using SVSim.Database.Repositories.Viewer;
|
||||
using SVSim.EmulatedEntrypoint.Constants;
|
||||
using SVSim.EmulatedEntrypoint.Infrastructure;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses;
|
||||
@@ -37,19 +37,6 @@ public class LoadController : SVSimController
|
||||
new CardSetIdentifier { SetId = 10010 }
|
||||
};
|
||||
|
||||
// The prod-captured globals JSON was seeded with snake_case_lower keys (see SVSim.Bootstrap
|
||||
// GlobalsImporter — jsonb columns store the original capture verbatim). Deserialize-back must
|
||||
// use the same naming policy so e.g. `card_pool_name` maps onto `CardPoolName`.
|
||||
//
|
||||
// AllowReadingFromString handles prod's PHP-backend convention of emitting numeric values
|
||||
// as JSON strings (e.g. `"ability_id": "1"`). Numeric-typed DTO properties accept those.
|
||||
private static readonly JsonSerializerOptions JsonbReadOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
PropertyNameCaseInsensitive = false,
|
||||
NumberHandling = JsonNumberHandling.AllowReadingFromString,
|
||||
};
|
||||
|
||||
private readonly IViewerRepository _viewerRepository;
|
||||
private readonly ICardRepository _cardRepository;
|
||||
private readonly ICollectionRepository _collectionRepository;
|
||||
@@ -257,7 +244,7 @@ public class LoadController : SVSimController
|
||||
ArenaFormatInfo? format = null;
|
||||
if (!string.IsNullOrEmpty(season.FormatInfo) && season.FormatInfo != "{}")
|
||||
{
|
||||
format = JsonSerializer.Deserialize<ArenaFormatInfo>(season.FormatInfo, JsonbReadOptions);
|
||||
format = JsonSerializer.Deserialize<ArenaFormatInfo>(season.FormatInfo, JsonbReadOptions.Instance);
|
||||
}
|
||||
|
||||
return new List<ArenaInfo>
|
||||
@@ -298,13 +285,13 @@ public class LoadController : SVSimController
|
||||
}),
|
||||
Abilities = abilities.ToDictionary(
|
||||
a => a.Id.ToString(),
|
||||
a => JsonSerializer.Deserialize<MyRotationAbility>(a.Data, JsonbReadOptions) ?? new MyRotationAbility()),
|
||||
a => JsonSerializer.Deserialize<MyRotationAbility>(a.Data, JsonbReadOptions.Instance) ?? new MyRotationAbility()),
|
||||
ReprintedCards = settings.ToDictionary(
|
||||
s => s.Id.ToString(),
|
||||
s => JsonSerializer.Deserialize<Dictionary<string, int>>(s.ReprintedCardIds, JsonbReadOptions) ?? new()),
|
||||
s => JsonSerializer.Deserialize<Dictionary<string, int>>(s.ReprintedCardIds, JsonbReadOptions.Instance) ?? new()),
|
||||
Banlist = settings.ToDictionary(
|
||||
s => s.Id.ToString(),
|
||||
s => JsonSerializer.Deserialize<Dictionary<string, int>>(s.RestrictedCardIds, JsonbReadOptions) ?? new()),
|
||||
s => JsonSerializer.Deserialize<Dictionary<string, int>>(s.RestrictedCardIds, JsonbReadOptions.Instance) ?? new()),
|
||||
DisabledCardSets = new List<int>(), // prod 2026-05-23 emits empty list; refine if/when populated
|
||||
Schedules = BuildMyRotationSchedules(),
|
||||
};
|
||||
@@ -369,9 +356,9 @@ public class LoadController : SVSimController
|
||||
PreReleaseCardMasterId = pri.PreReleaseCardMasterId,
|
||||
FreeMatchStartTime = pri.FreeMatchStartTime,
|
||||
CardMasterId = pri.CardMasterId,
|
||||
RotationCardSets = JsonSerializer.Deserialize<List<int>>(pri.RotationCardSetIdList, JsonbReadOptions) ?? new(),
|
||||
ReprintedCardIds = JsonSerializer.Deserialize<Dictionary<string, string>>(pri.ReprintedBaseCardIds, JsonbReadOptions) ?? new(),
|
||||
LatestReprintedCardIds = JsonSerializer.Deserialize<List<int>>(pri.LatestReprintedBaseCardIds, JsonbReadOptions) ?? new(),
|
||||
RotationCardSets = JsonSerializer.Deserialize<List<int>>(pri.RotationCardSetIdList, JsonbReadOptions.Instance) ?? new(),
|
||||
ReprintedCardIds = JsonSerializer.Deserialize<Dictionary<string, string>>(pri.ReprintedBaseCardIds, JsonbReadOptions.Instance) ?? new(),
|
||||
LatestReprintedCardIds = JsonSerializer.Deserialize<List<int>>(pri.LatestReprintedBaseCardIds, JsonbReadOptions.Instance) ?? new(),
|
||||
IsPreRotationFreeMatchTerm = pri.IsPreRotationFreeMatchTerm ? 1 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.Globals;
|
||||
using SVSim.Database.Repositories.Viewer;
|
||||
using SVSim.EmulatedEntrypoint.Constants;
|
||||
using SVSim.EmulatedEntrypoint.Infrastructure;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses;
|
||||
@@ -11,6 +14,10 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
public class MyPageController : SVSimController
|
||||
{
|
||||
/// <summary>"yyyy-MM-dd HH:mm:ss" — prod's PHP convention. Used for wire-formatting DateTime
|
||||
/// columns that the client parses via DateTime.Parse on its side.</summary>
|
||||
private const string WireDateFormat = "yyyy-MM-dd HH:mm:ss";
|
||||
|
||||
private readonly IViewerRepository _viewerRepository;
|
||||
private readonly IGlobalsRepository _globalsRepository;
|
||||
|
||||
@@ -38,9 +45,15 @@ public class MyPageController : SVSimController
|
||||
var deviceHeader = Request.Headers["DEVICE"].FirstOrDefault();
|
||||
int deviceType = int.TryParse(deviceHeader, out int parsed) ? parsed : 0;
|
||||
|
||||
// Stubs below are tagged TODO(mypage-stub). See the "Current server implementation"
|
||||
// section of docs/api-spec/endpoints/post-login/mypage-index.md for the table of what
|
||||
// each one would source from. Grep for "mypage-stub" to enumerate them.
|
||||
// Hydrate all the globals slices in parallel-ish — they're independent reads.
|
||||
var cfg = await _globalsRepository.GetGameConfiguration("default");
|
||||
var colosseum = await _globalsRepository.GetCurrentColosseum();
|
||||
var sealedSeason = await _globalsRepository.GetCurrentSealedSeason();
|
||||
var masterPointPeriod = await _globalsRepository.GetCurrentMasterPointPeriod();
|
||||
var bannerEntries = await _globalsRepository.GetBanners();
|
||||
var specialDeckFormats = await _globalsRepository.GetActiveSpecialDeckFormats();
|
||||
|
||||
// Remaining stubs are tagged TODO(mypage-stub) — see docs/api-spec/endpoints/post-login/mypage-index.md.
|
||||
return new MyPageIndexResponse
|
||||
{
|
||||
UserInfo = new UserInfo(deviceType, viewer),
|
||||
@@ -55,6 +68,19 @@ public class MyPageController : SVSimController
|
||||
ArenaInfo = await BuildArenaInfosAsync(),
|
||||
IsArenaChallengePeriod = false, // TODO(mypage-stub): globals/ArenaSeason flag
|
||||
IsAvailableColosseumFreeEntry = false, // TODO(mypage-stub): viewer + globals free-entry quota
|
||||
ColosseumInfo = BuildColosseumInfo(colosseum),
|
||||
SealedInfo = BuildSealedInfo(sealedSeason),
|
||||
Banner = bannerEntries.Select(BuildBannerInfo).ToList(),
|
||||
RoomTypeInSession = new RoomTypeInSession
|
||||
{
|
||||
SpecialDeckFormatList = specialDeckFormats
|
||||
.Select(e => new SpecialDeckFormat
|
||||
{
|
||||
DeckFormat = e.DeckFormat,
|
||||
EndTime = e.EndTime.ToString(WireDateFormat, CultureInfo.InvariantCulture)
|
||||
})
|
||||
.ToList()
|
||||
},
|
||||
Convention = new Convention // TODO(mypage-stub): viewer offline-event participation
|
||||
{
|
||||
IsJoinTournament = false,
|
||||
@@ -62,36 +88,61 @@ public class MyPageController : SVSimController
|
||||
},
|
||||
UserConfig = new UserConfig(), // TODO(mypage-stub): persist viewer UserConfig
|
||||
Quest = new Quest(), // TODO(mypage-stub): active Quest event + viewer flags
|
||||
MasterPointRankingPeriod = new MasterPointRankingPeriod
|
||||
{
|
||||
// TODO(mypage-stub): source begin_time/end_time/period_num/necessary_score from the
|
||||
// current Master Points season row in globals. Far-future fallback so the client's
|
||||
// DateTime.Parse(end_time) succeeds and _masterResetNextTime gets seeded.
|
||||
EndTime = "2030-01-01 00:00:00",
|
||||
},
|
||||
MasterPointRankingPeriod = BuildMasterPointRankingPeriod(masterPointPeriod),
|
||||
PreReleaseStatus = 0, // TODO(mypage-stub): derive from PreReleaseInfo
|
||||
UserMyPageInfo = new UserMyPageInfo // TODO(mypage-stub): viewer mypage BG selection
|
||||
{
|
||||
UserMyPageSetting = new MyPageBgSetting(),
|
||||
},
|
||||
BasicPuzzle = new BasicPuzzle { IsDisplayBadge = false }, // TODO(mypage-stub): viewer practice-puzzle progress
|
||||
IsBattlePassPeriod = (await _globalsRepository.GetGameConfiguration("default")).IsBattlePassPeriod,
|
||||
IsBattlePassPeriod = cfg.IsBattlePassPeriod,
|
||||
SpecialCrystalInfo = new(), // TODO(mypage-stub): same shape/source as /load/index
|
||||
// ColosseumInfo, ShopNotification, StoryNotification, IsHiddenBossAppeared all
|
||||
// default-constructed by MyPageIndexResponse's field initializers.
|
||||
// TODO(mypage-stub): wire colosseum_info from current Colosseum cup row.
|
||||
// CompetitionInfo, ShopNotification, StoryNotification, GuildNotification, GatheringInfo,
|
||||
// IsHiddenBossAppeared, SubBanner/SubBannerList/HomeDialogList/UserOfflineEvent/UserItemList,
|
||||
// and the three explicit-null fields (TreasureInfo, LotteryPeriodInfo, AllCardEnabledPeriod)
|
||||
// all rely on MyPageIndexResponse field initializers.
|
||||
// TODO(mypage-stub): wire competition_info from active tournament row (default false fine until tournaments exist).
|
||||
// TODO(mypage-stub): wire shop_notification from per-product shop-appeal state.
|
||||
// TODO(mypage-stub): wire story_notification from viewer story progress.
|
||||
// TODO(mypage-stub): wire is_hidden_boss_appeared from globals event flag.
|
||||
// TODO(mypage-stub): per-viewer state for user_item_list, gathering_info.is_entry, guild_notification, user_offline_event, home_dialog_list.
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Same shape as LoadController.BuildArenaInfosAsync, but /mypage/index has no
|
||||
/// Keys.Contains("arena_info") guard on the client (ArenaData(jsonData["arena_info"])
|
||||
/// at MyPageTask.cs:55 indexes [0] unconditionally). When no current Take Two season is
|
||||
/// seeded we fall back to a minimal one-entry list so the client's ArenaData ctor doesn't
|
||||
/// crash with IndexOutOfRange.
|
||||
/// Slim notification-delta endpoint — see MyPageRefreshResponse for the 3-field contract.
|
||||
/// Client fires this once after main-menu UI settles (and a second time shortly after; both
|
||||
/// calls get the same response). No state changes happen here; everything is read-only.
|
||||
/// </summary>
|
||||
[HttpPost("refresh")]
|
||||
public async Task<ActionResult<MyPageRefreshResponse>> Refresh(MyPageRefreshRequest request)
|
||||
{
|
||||
var shortUdidClaim = User.Claims.FirstOrDefault(c => c.Type == ShadowverseClaimTypes.ShortUdidClaim)?.Value;
|
||||
if (shortUdidClaim is null || !long.TryParse(shortUdidClaim, out long shortUdid))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
Viewer? viewer = await _viewerRepository.GetViewerByShortUdid(shortUdid);
|
||||
if (viewer is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return new MyPageRefreshResponse
|
||||
{
|
||||
FriendBattleInviteCount = 0, // TODO(mypage-stub): viewer room-invite count
|
||||
ShopNotification = new ShopNotification(), // TODO(mypage-stub): per-product shop-appeal state
|
||||
GatheringNotification = new GatheringNotification(), // empty matching message — correct for fresh viewers
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors LoadController.BuildArenaInfosAsync. /mypage/index has no Keys.Contains("arena_info")
|
||||
/// guard (ArenaData(jsonData["arena_info"]) at MyPageTask.cs:55 indexes [0] unconditionally), and
|
||||
/// the post-parse UI consumer (ChallengeEntry.SetChallengeInfo at ChallengeEntry.cs:35) reads
|
||||
/// _twoPickData.ChallengeData which is only built when arena_info[0].format_info is present.
|
||||
/// So we always populate format_info from the same ArenaSeason.FormatInfo jsonb /load/index uses.
|
||||
/// </summary>
|
||||
private async Task<List<ArenaInfo>> BuildArenaInfosAsync()
|
||||
{
|
||||
@@ -112,6 +163,12 @@ public class MyPageController : SVSimController
|
||||
};
|
||||
}
|
||||
|
||||
ArenaFormatInfo? format = null;
|
||||
if (!string.IsNullOrEmpty(season.FormatInfo) && season.FormatInfo != "{}")
|
||||
{
|
||||
format = JsonSerializer.Deserialize<ArenaFormatInfo>(season.FormatInfo, JsonbReadOptions.Instance);
|
||||
}
|
||||
|
||||
return new List<ArenaInfo>
|
||||
{
|
||||
new ArenaInfo
|
||||
@@ -122,10 +179,115 @@ public class MyPageController : SVSimController
|
||||
RupeeCost = season.RupyCost,
|
||||
TicketCost = season.TicketCost,
|
||||
IsJoin = season.IsJoin,
|
||||
// format_info is intentionally omitted here — /mypage/index's ArenaData
|
||||
// ctor only needs the top-level fields. /load/index round-trips it via
|
||||
// JsonbReadOptions; pull it in if a downstream check ever needs it.
|
||||
FormatInfo = format,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private ColosseumInfo BuildColosseumInfo(ColosseumConfig? row)
|
||||
{
|
||||
if (row is null) return new ColosseumInfo();
|
||||
|
||||
ColosseumSalesPeriodInfo sales = new();
|
||||
if (!string.IsNullOrEmpty(row.SalesPeriodInfo) && row.SalesPeriodInfo != "{}")
|
||||
{
|
||||
sales = JsonSerializer.Deserialize<ColosseumSalesPeriodInfo>(row.SalesPeriodInfo, JsonbReadOptions.Instance)
|
||||
?? new ColosseumSalesPeriodInfo();
|
||||
}
|
||||
|
||||
return new ColosseumInfo
|
||||
{
|
||||
ColosseumId = row.ColosseumId,
|
||||
IsDisplayTips = row.IsDisplayTips,
|
||||
TipsId = row.TipsId,
|
||||
CardPoolName = row.CardPoolName,
|
||||
IsColosseumPeriod = row.IsColosseumPeriod,
|
||||
IsRoundPeriod = row.IsRoundPeriod,
|
||||
DeckFormat = row.DeckFormat,
|
||||
IsNormalTwoPick = row.IsNormalTwoPick,
|
||||
IsSpecialMode = row.IsSpecialMode,
|
||||
IsAllCardEnabled = row.IsAllCardEnabled,
|
||||
StartTime = row.StartTime.ToString(WireDateFormat, CultureInfo.InvariantCulture),
|
||||
ColosseumName = row.ColosseumName,
|
||||
NowRound = row.NowRound,
|
||||
EndTime = row.EndTime.ToString(WireDateFormat, CultureInfo.InvariantCulture),
|
||||
SalesPeriodInfo = sales,
|
||||
};
|
||||
}
|
||||
|
||||
private SealedInfo BuildSealedInfo(SealedConfig? row)
|
||||
{
|
||||
if (row is null) return new SealedInfo();
|
||||
|
||||
List<int> packInfo = new();
|
||||
if (!string.IsNullOrEmpty(row.PackInfo) && row.PackInfo != "[]")
|
||||
{
|
||||
packInfo = JsonSerializer.Deserialize<List<int>>(row.PackInfo, JsonbReadOptions.Instance) ?? new();
|
||||
}
|
||||
|
||||
SealedSalesPeriodInfo sales = new();
|
||||
if (!string.IsNullOrEmpty(row.SalesPeriodInfo) && row.SalesPeriodInfo != "{}")
|
||||
{
|
||||
sales = JsonSerializer.Deserialize<SealedSalesPeriodInfo>(row.SalesPeriodInfo, JsonbReadOptions.Instance)
|
||||
?? new SealedSalesPeriodInfo();
|
||||
}
|
||||
|
||||
return new SealedInfo
|
||||
{
|
||||
Enable = row.Enable,
|
||||
CrystalCost = row.CrystalCost,
|
||||
RupyCost = row.RupyCost,
|
||||
TicketCost = row.TicketCost,
|
||||
IsJoin = row.IsJoin,
|
||||
PackInfo = packInfo,
|
||||
DeckUsingNumMin = row.DeckUsingNumMin,
|
||||
ScheduleId = row.ScheduleId,
|
||||
IsDeckCodeMaintenance = row.IsDeckCodeMaintenance,
|
||||
SalesPeriodInfo = sales,
|
||||
};
|
||||
}
|
||||
|
||||
private static BannerInfo BuildBannerInfo(BannerEntry row)
|
||||
{
|
||||
List<string> imagePaths = new();
|
||||
if (!string.IsNullOrEmpty(row.ImagePaths) && row.ImagePaths != "[]")
|
||||
{
|
||||
imagePaths = JsonSerializer.Deserialize<List<string>>(row.ImagePaths, JsonbReadOptions.Instance) ?? new();
|
||||
}
|
||||
|
||||
return new BannerInfo
|
||||
{
|
||||
ImageName = row.ImageName,
|
||||
Click = row.Click,
|
||||
Status = row.Status,
|
||||
// DB stores numeric, wire is string. PHP convention.
|
||||
ChangeTime = row.ChangeTime.ToString(CultureInfo.InvariantCulture),
|
||||
RemainingTime = row.RemainingTime.ToString(CultureInfo.InvariantCulture),
|
||||
ImagePaths = imagePaths,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Far-future fallback EndTime so the client's DateTime.Parse(end_time) succeeds and
|
||||
/// Data.Load.data._masterResetNextTime gets seeded even when no globals row is present.
|
||||
/// </summary>
|
||||
private static MasterPointRankingPeriod BuildMasterPointRankingPeriod(MasterPointRankingPeriodEntry? row)
|
||||
{
|
||||
if (row is null)
|
||||
{
|
||||
return new MasterPointRankingPeriod
|
||||
{
|
||||
EndTime = "2030-01-01 00:00:00",
|
||||
};
|
||||
}
|
||||
|
||||
return new MasterPointRankingPeriod
|
||||
{
|
||||
Id = row.Id,
|
||||
PeriodNum = row.PeriodNum,
|
||||
NecessaryScore = row.NecessaryScore,
|
||||
BeginTime = row.BeginTime.ToString(WireDateFormat, CultureInfo.InvariantCulture),
|
||||
EndTime = row.EndTime.ToString(WireDateFormat, CultureInfo.InvariantCulture),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
70
SVSim.EmulatedEntrypoint/Controllers/PaymentController.cs
Normal file
70
SVSim.EmulatedEntrypoint/Controllers/PaymentController.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.Globals;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// /payment_pc/* — Steam/PC store endpoints. Currently serves item_list (the storefront product
|
||||
/// catalog); purchase flows (/payment_pc/finish etc.) are not yet implemented.
|
||||
///
|
||||
/// Route is explicit because the URL prefix doesn't match the controller name pattern
|
||||
/// (SVSimController applies [Route("[controller]")] which would resolve to /payment).
|
||||
/// </summary>
|
||||
[Route("payment_pc")]
|
||||
public class PaymentController : SVSimController
|
||||
{
|
||||
/// <summary>"yyyy-MM-dd HH:mm:ss" — prod's PHP datetime convention on the wire.</summary>
|
||||
private const string WireDateFormat = "yyyy-MM-dd HH:mm:ss";
|
||||
|
||||
private readonly IGlobalsRepository _globalsRepository;
|
||||
|
||||
public PaymentController(IGlobalsRepository globalsRepository)
|
||||
{
|
||||
_globalsRepository = globalsRepository;
|
||||
}
|
||||
|
||||
[HttpPost("item_list")]
|
||||
public async Task<ActionResult<Dictionary<string, PaymentItemInfo>>> ItemList(PaymentItemListRequest request)
|
||||
{
|
||||
var items = await _globalsRepository.GetPaymentItems();
|
||||
return items.ToDictionary(
|
||||
row => row.StoreProductId.ToString(CultureInfo.InvariantCulture),
|
||||
row => BuildPaymentItemInfo(row));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map a typed DB row to the all-strings wire shape prod uses. Typed columns let us query and
|
||||
/// validate cleanly server-side; PHP-stringification happens here at the wire boundary.
|
||||
/// </summary>
|
||||
private static PaymentItemInfo BuildPaymentItemInfo(PaymentItemEntry row) => new()
|
||||
{
|
||||
RecordId = row.Id.ToString(CultureInfo.InvariantCulture),
|
||||
Id = row.ProductId.ToString(CultureInfo.InvariantCulture),
|
||||
StoreProductId = row.StoreProductId.ToString(CultureInfo.InvariantCulture),
|
||||
Name = row.Name,
|
||||
Text = row.Text,
|
||||
// Prod price wire shape is e.g. "0.99" with up to 2 decimals. InvariantCulture renders the
|
||||
// .NET decimal as "0.99" / "10.99" cleanly without trailing zeros from a scale of 4+.
|
||||
Price = row.Price.ToString("0.##", CultureInfo.InvariantCulture),
|
||||
ChargeCrystalNum = row.ChargeCrystalNum.ToString(CultureInfo.InvariantCulture),
|
||||
FreeCrystalNum = row.FreeCrystalNum.ToString(CultureInfo.InvariantCulture),
|
||||
PurchaseLimit = row.PurchaseLimit.ToString(CultureInfo.InvariantCulture),
|
||||
SpecialShopFlag = row.SpecialShopFlag.ToString(CultureInfo.InvariantCulture),
|
||||
ImageName = row.ImageName,
|
||||
StartTime = row.StartTime.ToString(WireDateFormat, CultureInfo.InvariantCulture),
|
||||
EndTime = row.EndTime.ToString(WireDateFormat, CultureInfo.InvariantCulture),
|
||||
RemainingTime = row.RemainingTime.ToString(CultureInfo.InvariantCulture),
|
||||
IsResaleProduct = row.IsResaleProduct.ToString(CultureInfo.InvariantCulture),
|
||||
// Prod sends "" when no resale window is scheduled; otherwise the formatted date.
|
||||
ResaleStartDate = row.ResaleStartDate is { } d
|
||||
? d.ToString(WireDateFormat, CultureInfo.InvariantCulture)
|
||||
: string.Empty,
|
||||
// TODO(payment-stub): per-viewer count of this product's purchases. Hardcoded to 0 until
|
||||
// viewer-purchase tracking lands. Fresh viewers always see 0 in prod anyway.
|
||||
PurchaseNumCurrent = 0,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user