feat(home-dialog): populate home_dialog_list on /mypage/index
Walk-down behavior: each call emits the highest-priority unfired active dialog; subsequent calls walk to the next-priority entry. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ 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;
|
||||||
|
using SVSim.EmulatedEntrypoint.Services;
|
||||||
|
|
||||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||||
|
|
||||||
@@ -24,14 +25,17 @@ public class MyPageController : SVSimController
|
|||||||
private readonly IGlobalsRepository _globalsRepository;
|
private readonly IGlobalsRepository _globalsRepository;
|
||||||
private readonly IGameConfigService _config;
|
private readonly IGameConfigService _config;
|
||||||
private readonly IArenaTwoPickRunRepository _arenaTwoPickRuns;
|
private readonly IArenaTwoPickRunRepository _arenaTwoPickRuns;
|
||||||
|
private readonly IHomeDialogSessionTracker _homeDialogTracker;
|
||||||
|
|
||||||
public MyPageController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository,
|
public MyPageController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository,
|
||||||
IGameConfigService config, IArenaTwoPickRunRepository arenaTwoPickRuns)
|
IGameConfigService config, IArenaTwoPickRunRepository arenaTwoPickRuns,
|
||||||
|
IHomeDialogSessionTracker homeDialogTracker)
|
||||||
{
|
{
|
||||||
_viewerRepository = viewerRepository;
|
_viewerRepository = viewerRepository;
|
||||||
_globalsRepository = globalsRepository;
|
_globalsRepository = globalsRepository;
|
||||||
_config = config;
|
_config = config;
|
||||||
_arenaTwoPickRuns = arenaTwoPickRuns;
|
_arenaTwoPickRuns = arenaTwoPickRuns;
|
||||||
|
_homeDialogTracker = homeDialogTracker;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("index")]
|
[HttpPost("index")]
|
||||||
@@ -59,6 +63,17 @@ public class MyPageController : SVSimController
|
|||||||
var masterPointPeriod = await _globalsRepository.GetCurrentMasterPointPeriod();
|
var masterPointPeriod = await _globalsRepository.GetCurrentMasterPointPeriod();
|
||||||
var bannerEntries = await _globalsRepository.GetBanners();
|
var bannerEntries = await _globalsRepository.GetBanners();
|
||||||
var specialDeckFormats = await _globalsRepository.GetActiveSpecialDeckFormats();
|
var specialDeckFormats = await _globalsRepository.GetActiveSpecialDeckFormats();
|
||||||
|
var activeHomeDialogs = await _globalsRepository.GetActiveHomeDialogsAsync(DateTime.UtcNow);
|
||||||
|
|
||||||
|
var homeDialogList = new List<Models.Dtos.Common.HomeDialog>();
|
||||||
|
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.
|
// Remaining stubs are tagged TODO(mypage-stub) — see docs/api-spec/endpoints/post-login/mypage-index.md.
|
||||||
return new MyPageIndexResponse
|
return new MyPageIndexResponse
|
||||||
@@ -110,6 +125,7 @@ public class MyPageController : SVSimController
|
|||||||
// out is_hide=1 tutorial packs (the legendary starter 99047) via PackConfig.EnableBuyPack.
|
// 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.
|
// Populate from viewer.Items so the client's dict stays in sync with the DB.
|
||||||
UserItemList = viewer.Items.Select(i => new UserItem(i)).ToList(),
|
UserItemList = viewer.Items.Select(i => new UserItem(i)).ToList(),
|
||||||
|
HomeDialogList = homeDialogList,
|
||||||
SpecialCrystalInfo = new(), // TODO(mypage-stub): same shape/source as /load/index
|
SpecialCrystalInfo = new(), // TODO(mypage-stub): same shape/source as /load/index
|
||||||
// CompetitionInfo, ShopNotification, StoryNotification, GuildNotification, GatheringInfo,
|
// CompetitionInfo, ShopNotification, StoryNotification, GuildNotification, GatheringInfo,
|
||||||
// IsHiddenBossAppeared, SubBanner/SubBannerList/HomeDialogList/UserOfflineEvent/UserItemList,
|
// IsHiddenBossAppeared, SubBanner/SubBannerList/HomeDialogList/UserOfflineEvent/UserItemList,
|
||||||
@@ -268,6 +284,32 @@ public class MyPageController : SVSimController
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private static Models.Dtos.Common.HomeDialog BuildHomeDialog(HomeDialogEntry row)
|
||||||
|
{
|
||||||
|
List<Models.Dtos.Common.HomeDialogButtonDto> buttons = new();
|
||||||
|
if (!string.IsNullOrEmpty(row.ButtonListJson) && row.ButtonListJson != "[]")
|
||||||
|
{
|
||||||
|
buttons = JsonSerializer.Deserialize<List<Models.Dtos.Common.HomeDialogButtonDto>>(
|
||||||
|
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)
|
private static BannerInfo BuildBannerInfo(BannerEntry row)
|
||||||
{
|
{
|
||||||
List<string> imagePaths = new();
|
List<string> imagePaths = new();
|
||||||
|
|||||||
37
SVSim.EmulatedEntrypoint/Models/Dtos/Common/HomeDialog.cs
Normal file
37
SVSim.EmulatedEntrypoint/Models/Dtos/Common/HomeDialog.cs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using MessagePack;
|
||||||
|
|
||||||
|
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
[MessagePackObject]
|
||||||
|
public class HomeDialog
|
||||||
|
{
|
||||||
|
/// <summary>Wire "type" — prod sends "1"; client parser ignores it. Stringly-typed.
|
||||||
|
/// Null is omitted by the global WhenWritingNull policy.</summary>
|
||||||
|
[JsonPropertyName("type")] [Key("type")] public string? Type { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Localization key resolved client-side via Data.SystemText.Get.</summary>
|
||||||
|
[JsonPropertyName("title_text_id")] [Key("title_text_id")] public string TitleTextId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Asset name resolved via ResourcesManager.AssetLoadPathType.UiDownLoad.</summary>
|
||||||
|
[JsonPropertyName("image")] [Key("image")] public string Image { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("button_list")] [Key("button_list")] public List<HomeDialogButtonDto> ButtonList { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public class HomeDialogButtonDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("button_text_id")] [Key("button_text_id")] public string ButtonTextId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Scene id consumed by MyPageBannerBase.SceneChangeBySetting (e.g. "card_pack", "mission").</summary>
|
||||||
|
[JsonPropertyName("scene")] [Key("scene")] public string Scene { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Contextual id passed to the scene (e.g. parent_gacha_id "80032"). Stringly-typed on the wire.</summary>
|
||||||
|
[JsonPropertyName("status")] [Key("status")] public string Status { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -123,7 +123,7 @@ public class MyPageIndexResponse
|
|||||||
|
|
||||||
[JsonPropertyName("home_dialog_list")]
|
[JsonPropertyName("home_dialog_list")]
|
||||||
[Key("home_dialog_list")]
|
[Key("home_dialog_list")]
|
||||||
public List<object> HomeDialogList { get; set; } = new();
|
public List<Common.HomeDialog> HomeDialogList { get; set; } = new();
|
||||||
|
|
||||||
// ── Room type in session (Special-format windows) ──────────────────────
|
// ── Room type in session (Special-format windows) ──────────────────────
|
||||||
|
|
||||||
|
|||||||
152
SVSim.UnitTests/Controllers/MyPageControllerHomeDialogTests.cs
Normal file
152
SVSim.UnitTests/Controllers/MyPageControllerHomeDialogTests.cs
Normal file
@@ -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<JsonElement> 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<SVSimDbContext>();
|
||||||
|
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<SVSimDbContext>();
|
||||||
|
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<SVSimDbContext>();
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user