diff --git a/SVSim.EmulatedEntrypoint/Controllers/MyPageController.cs b/SVSim.EmulatedEntrypoint/Controllers/MyPageController.cs index 2e76b5f..1beb1c2 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/MyPageController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/MyPageController.cs @@ -11,6 +11,7 @@ using SVSim.EmulatedEntrypoint.Infrastructure; using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses; +using SVSim.EmulatedEntrypoint.Services; namespace SVSim.EmulatedEntrypoint.Controllers; @@ -24,14 +25,17 @@ public class MyPageController : SVSimController private readonly IGlobalsRepository _globalsRepository; private readonly IGameConfigService _config; private readonly IArenaTwoPickRunRepository _arenaTwoPickRuns; + private readonly IHomeDialogSessionTracker _homeDialogTracker; public MyPageController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository, - IGameConfigService config, IArenaTwoPickRunRepository arenaTwoPickRuns) + IGameConfigService config, IArenaTwoPickRunRepository arenaTwoPickRuns, + IHomeDialogSessionTracker homeDialogTracker) { _viewerRepository = viewerRepository; _globalsRepository = globalsRepository; _config = config; _arenaTwoPickRuns = arenaTwoPickRuns; + _homeDialogTracker = homeDialogTracker; } [HttpPost("index")] @@ -59,6 +63,17 @@ public class MyPageController : SVSimController var masterPointPeriod = await _globalsRepository.GetCurrentMasterPointPeriod(); var bannerEntries = await _globalsRepository.GetBanners(); var specialDeckFormats = await _globalsRepository.GetActiveSpecialDeckFormats(); + var activeHomeDialogs = await _globalsRepository.GetActiveHomeDialogsAsync(DateTime.UtcNow); + + var homeDialogList = new List(); + foreach (var entry in activeHomeDialogs) + { + if (_homeDialogTracker.TryReserve(viewer.ShortUdid, entry.Id)) + { + homeDialogList.Add(BuildHomeDialog(entry)); + break; // Client only reads [0]; emit at most one per call. + } + } // Remaining stubs are tagged TODO(mypage-stub) — see docs/api-spec/endpoints/post-login/mypage-index.md. return new MyPageIndexResponse @@ -110,6 +125,7 @@ public class MyPageController : SVSimController // out is_hide=1 tutorial packs (the legendary starter 99047) via PackConfig.EnableBuyPack. // Populate from viewer.Items so the client's dict stays in sync with the DB. UserItemList = viewer.Items.Select(i => new UserItem(i)).ToList(), + HomeDialogList = homeDialogList, SpecialCrystalInfo = new(), // TODO(mypage-stub): same shape/source as /load/index // CompetitionInfo, ShopNotification, StoryNotification, GuildNotification, GatheringInfo, // IsHiddenBossAppeared, SubBanner/SubBannerList/HomeDialogList/UserOfflineEvent/UserItemList, @@ -268,6 +284,32 @@ public class MyPageController : SVSimController }; } + /// + /// Deserializes the jsonb button_list column into wire-shape DTOs. Truncates >3 buttons — + /// the client's switch in MyPageHomeDialog.InitializeButtonAction only handles 0/1/2/3, + /// extras would be silently ignored anyway; doing it server-side keeps the wire honest. + /// + private static Models.Dtos.Common.HomeDialog BuildHomeDialog(HomeDialogEntry row) + { + List buttons = new(); + if (!string.IsNullOrEmpty(row.ButtonListJson) && row.ButtonListJson != "[]") + { + buttons = JsonSerializer.Deserialize>( + row.ButtonListJson, JsonbReadOptions.Instance) ?? new(); + } + if (buttons.Count > 3) + { + buttons = buttons.Take(3).ToList(); + } + return new Models.Dtos.Common.HomeDialog + { + Type = row.Type?.ToString(CultureInfo.InvariantCulture), + TitleTextId = row.TitleTextId, + Image = row.Image, + ButtonList = buttons, + }; + } + private static BannerInfo BuildBannerInfo(BannerEntry row) { List imagePaths = new(); diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Common/HomeDialog.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Common/HomeDialog.cs new file mode 100644 index 0000000..55a8c38 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Common/HomeDialog.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Serialization; +using MessagePack; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common; + +/// +/// One entry in /mypage/index data.home_dialog_list. Client parser +/// (Wizard/MyPageHomeDialogData.cs) only reads [0]; up to 3 buttons supported +/// (switch on 0/1/2/3 in MyPageHomeDialog.cs). +/// +[MessagePackObject] +public class HomeDialog +{ + /// Wire "type" — prod sends "1"; client parser ignores it. Stringly-typed. + /// Null is omitted by the global WhenWritingNull policy. + [JsonPropertyName("type")] [Key("type")] public string? Type { get; set; } + + /// Localization key resolved client-side via Data.SystemText.Get. + [JsonPropertyName("title_text_id")] [Key("title_text_id")] public string TitleTextId { get; set; } = string.Empty; + + /// Asset name resolved via ResourcesManager.AssetLoadPathType.UiDownLoad. + [JsonPropertyName("image")] [Key("image")] public string Image { get; set; } = string.Empty; + + [JsonPropertyName("button_list")] [Key("button_list")] public List ButtonList { get; set; } = new(); +} + +[MessagePackObject] +public class HomeDialogButtonDto +{ + [JsonPropertyName("button_text_id")] [Key("button_text_id")] public string ButtonTextId { get; set; } = string.Empty; + + /// Scene id consumed by MyPageBannerBase.SceneChangeBySetting (e.g. "card_pack", "mission"). + [JsonPropertyName("scene")] [Key("scene")] public string Scene { get; set; } = string.Empty; + + /// Contextual id passed to the scene (e.g. parent_gacha_id "80032"). Stringly-typed on the wire. + [JsonPropertyName("status")] [Key("status")] public string Status { get; set; } = string.Empty; +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/MyPageIndexResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/MyPageIndexResponse.cs index c042f57..3e3613f 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/MyPageIndexResponse.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/MyPageIndexResponse.cs @@ -123,7 +123,7 @@ public class MyPageIndexResponse [JsonPropertyName("home_dialog_list")] [Key("home_dialog_list")] - public List HomeDialogList { get; set; } = new(); + public List HomeDialogList { get; set; } = new(); // ── Room type in session (Special-format windows) ────────────────────── diff --git a/SVSim.UnitTests/Controllers/MyPageControllerHomeDialogTests.cs b/SVSim.UnitTests/Controllers/MyPageControllerHomeDialogTests.cs new file mode 100644 index 0000000..5ae4c07 --- /dev/null +++ b/SVSim.UnitTests/Controllers/MyPageControllerHomeDialogTests.cs @@ -0,0 +1,152 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Models; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Controllers; + +public class MyPageControllerHomeDialogTests +{ + private const string BaseAuthBlock = + @"""viewer_id"":""0"",""steam_id"":0,""steam_session_ticket"":"""""; + + private const string RequestBody = + $$"""{"carrier":"",{{BaseAuthBlock}}}"""; + + private static async Task PostIndexAsync(SVSimTestFactory factory, HttpClient client) + { + var resp = await client.PostAsync("/mypage/index", + new StringContent(RequestBody, Encoding.UTF8, "application/json")); + Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); + return doc.RootElement.Clone(); + } + + private static async Task SeedDialogAsync(SVSimTestFactory factory, HomeDialogEntry entry) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.HomeDialogEntries.Add(entry); + await db.SaveChangesAsync(); + } + + [Test] + public async Task Index_returns_active_home_dialog_on_first_call_of_session() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var now = DateTime.UtcNow; + await SeedDialogAsync(factory, new HomeDialogEntry + { + Id = 1, + TitleTextId = "HomeDialog_0066", + Image = "home_dialog_000312", + ButtonListJson = """[{"button_text_id":"HomeDialog_0002","scene":"card_pack","status":"80032"}]""", + BeginTime = now.AddHours(-1), + EndTime = now.AddHours(1), + Type = 1, + Priority = 0, + }); + + var root = await PostIndexAsync(factory, client); + var list = root.GetProperty("home_dialog_list"); + Assert.That(list.GetArrayLength(), Is.EqualTo(1)); + + var entry = list[0]; + Assert.That(entry.GetProperty("type").GetString(), Is.EqualTo("1")); + Assert.That(entry.GetProperty("title_text_id").GetString(), Is.EqualTo("HomeDialog_0066")); + Assert.That(entry.GetProperty("image").GetString(), Is.EqualTo("home_dialog_000312")); + + var buttons = entry.GetProperty("button_list"); + Assert.That(buttons.GetArrayLength(), Is.EqualTo(1)); + Assert.That(buttons[0].GetProperty("button_text_id").GetString(), Is.EqualTo("HomeDialog_0002")); + Assert.That(buttons[0].GetProperty("scene").GetString(), Is.EqualTo("card_pack")); + Assert.That(buttons[0].GetProperty("status").GetString(), Is.EqualTo("80032")); + } + + [Test] + public async Task Index_suppresses_already_fired_dialog_on_second_call() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var now = DateTime.UtcNow; + await SeedDialogAsync(factory, new HomeDialogEntry + { + Id = 1, + TitleTextId = "HomeDialog_0066", + Image = "home_dialog_000312", + ButtonListJson = "[]", + BeginTime = now.AddHours(-1), + EndTime = now.AddHours(1), + Priority = 0, + }); + + var first = await PostIndexAsync(factory, client); + Assert.That(first.GetProperty("home_dialog_list").GetArrayLength(), Is.EqualTo(1), + "First call must emit the active dialog."); + + var second = await PostIndexAsync(factory, client); + Assert.That(second.GetProperty("home_dialog_list").GetArrayLength(), Is.EqualTo(0), + "Second call must suppress the already-fired dialog."); + } + + [Test] + public async Task Index_skips_expired_and_not_yet_active_dialogs() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var now = DateTime.UtcNow; + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.HomeDialogEntries.AddRange( + new HomeDialogEntry { Id = 1, TitleTextId = "expired", Image = "i", ButtonListJson = "[]", BeginTime = now.AddDays(-30), EndTime = now.AddDays(-1) }, + new HomeDialogEntry { Id = 2, TitleTextId = "not-yet", Image = "i", ButtonListJson = "[]", BeginTime = now.AddDays(1), EndTime = now.AddDays(30) } + ); + await db.SaveChangesAsync(); + } + + var root = await PostIndexAsync(factory, client); + Assert.That(root.GetProperty("home_dialog_list").GetArrayLength(), Is.EqualTo(0)); + } + + [Test] + public async Task Index_picks_highest_priority_first_then_walks_down_on_subsequent_calls() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var now = DateTime.UtcNow; + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.HomeDialogEntries.AddRange( + new HomeDialogEntry { Id = 1, TitleTextId = "low", Image = "i", ButtonListJson = "[]", BeginTime = now.AddHours(-1), EndTime = now.AddHours(1), Priority = 5 }, + new HomeDialogEntry { Id = 2, TitleTextId = "high", Image = "i", ButtonListJson = "[]", BeginTime = now.AddHours(-1), EndTime = now.AddHours(1), Priority = 10 } + ); + await db.SaveChangesAsync(); + } + + var first = await PostIndexAsync(factory, client); + var second = await PostIndexAsync(factory, client); + var third = await PostIndexAsync(factory, client); + + Assert.That(first.GetProperty("home_dialog_list")[0].GetProperty("title_text_id").GetString(), + Is.EqualTo("high"), "First call must emit the highest-priority dialog."); + Assert.That(second.GetProperty("home_dialog_list")[0].GetProperty("title_text_id").GetString(), + Is.EqualTo("low"), "Second call must walk down to the next-priority unfired dialog."); + Assert.That(third.GetProperty("home_dialog_list").GetArrayLength(), Is.EqualTo(0), + "Third call must be empty — both dialogs fired."); + } +}