diff --git a/SVSim.Database/Models/Config/ColosseumSeasonConfig.cs b/SVSim.Database/Models/Config/ColosseumSeasonConfig.cs index 4c3caddc..1ef204f8 100644 --- a/SVSim.Database/Models/Config/ColosseumSeasonConfig.cs +++ b/SVSim.Database/Models/Config/ColosseumSeasonConfig.cs @@ -58,6 +58,14 @@ public class ColosseumSeasonConfig /// Number of strategies offered per pick in 2-Pick Chaos mode. public int StrategyPickNum { get; set; } + /// Drives the client's first-time tutorial tip popup for this cup + /// (Wizard/ColosseumEntryInfoTask.cs:129: NeedsFirstTips = (is_display_tips == 1)). + /// Server emits wire int 0/1. + public bool IsDisplayTips { get; set; } + + /// Tutorial tip content id, paired with . Wire int. + public int TipsId { get; set; } + public DateTime SalesPeriodStart { get; set; } public DateTime SalesPeriodEnd { get; set; } diff --git a/SVSim.EmulatedEntrypoint/Controllers/ArenaColosseumController.cs b/SVSim.EmulatedEntrypoint/Controllers/ArenaColosseumController.cs index 271b9a2f..bffa6b7a 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/ArenaColosseumController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/ArenaColosseumController.cs @@ -688,8 +688,8 @@ public class ArenaColosseumController : SVSimController // --- helpers --- - private static ColosseumLobbyInfo BuildColosseumInfo(ColosseumSeasonConfig season) => - ColosseumLobbyInfoBuilder.Build(season); + private ColosseumLobbyInfo BuildColosseumInfo(ColosseumSeasonConfig season) => + ColosseumLobbyInfoBuilder.Build(season, _config.Get(), DateTime.UtcNow); /// Builds the colosseum_status block. When the viewer has no run, every /// property is null and global WhenWritingNull renders {} — the client diff --git a/SVSim.EmulatedEntrypoint/Controllers/MyPageController.cs b/SVSim.EmulatedEntrypoint/Controllers/MyPageController.cs index 14bce05b..a94d4491 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/MyPageController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/MyPageController.cs @@ -94,7 +94,8 @@ public class MyPageController : SVSimController ArenaInfo = await BuildArenaInfosAsync(viewer.Id), IsArenaChallengePeriod = false, // TODO(mypage-stub): globals/ArenaSeason flag IsAvailableColosseumFreeEntry = false, // TODO(mypage-stub): viewer + globals free-entry quota - ColosseumInfo = ColosseumLobbyInfoBuilder.Build(colosseumSeason), + ColosseumInfo = ColosseumLobbyInfoBuilder.Build( + colosseumSeason, _config.Get(), DateTime.UtcNow), SealedInfo = BuildSealedInfo(sealedSeason), Banner = bannerEntries.Select(BuildBannerInfo).ToList(), RoomTypeInSession = new RoomTypeInSession diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/ArenaColosseum/ColosseumLobbyInfoBuilder.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/ArenaColosseum/ColosseumLobbyInfoBuilder.cs index ead36a8c..0b444e40 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/ArenaColosseum/ColosseumLobbyInfoBuilder.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/ArenaColosseum/ColosseumLobbyInfoBuilder.cs @@ -4,36 +4,61 @@ using SVSim.Database.Models.Config; namespace SVSim.EmulatedEntrypoint.Models.Dtos.ArenaColosseum; /// -/// Single source of truth for projecting onto the wire -/// block. Used by both /arena_colosseum/{top,get_fee_info} -/// AND /mypage/index data.colosseum_info — the home-screen tab and the lobby reads -/// must agree on the season state, so they go through one builder. When -/// IsColosseumPeriod = false the minimal "no event" payload is emitted; the client +/// Single source of truth for projecting + +/// onto the wire block. +/// Used by both /arena_colosseum/{top,get_fee_info} and +/// /mypage/index data.colosseum_info — the home-screen tab and the lobby reads must +/// agree on the season state, so they go through one builder. +/// +/// When IsColosseumPeriod = false a minimal "no event" payload is emitted; the client /// gates every other field on that flag (Wizard/ColosseumEntryInfoTask.cs:100). +/// +/// +/// Round-period derivation: are walked against +/// . If a round's window covers , we emit +/// is_round_period = true + that round's window as start_time/end_time + +/// now_round. Otherwise we emit is_round_period = false + the NEXT round's +/// start (or the event start if no rounds are configured) so the client's +/// "Round X opens in HH:MM:SS" countdown points somewhere sensible. Client handles both +/// branches at ColosseumEntryInfoTask.cs:111-126. +/// /// public static class ColosseumLobbyInfoBuilder { private const string WireDateFormat = "yyyy-MM-dd HH:mm:ss"; - public static ColosseumLobbyInfo Build(ColosseumSeasonConfig season) + public static ColosseumLobbyInfo Build( + ColosseumSeasonConfig season, + ColosseumRoundsConfig rounds, + DateTime nowUtc) { if (!season.IsColosseumPeriod) { return new ColosseumLobbyInfo { IsColosseumPeriod = false }; } + var (isRoundPeriod, nowRoundId, nextRoundId, windowStart, windowEnd) = + ResolveRoundWindow(season, rounds, nowUtc); + return new ColosseumLobbyInfo { IsColosseumPeriod = true, + // Wire colosseum_id — surface SeasonId so client can disambiguate cups. + // Falls back to absent when no season identity is configured. + ColosseumId = season.SeasonId == 0 ? null : season.SeasonId, DeckFormat = (int)season.DeckFormat, IsNormalTwoPick = season.IsNormalTwoPick ? "1" : "0", ColosseumName = season.ColosseumName, - IsRoundPeriod = true, + IsRoundPeriod = isRoundPeriod, IsSpecialMode = season.IsSpecialMode, CardPoolName = string.IsNullOrEmpty(season.CardPoolName) ? null : season.CardPoolName, - NowRound = 1, - StartTime = FormatTime(season.EventStartTime), - EndTime = FormatTime(season.EventEndTime), + NowRound = isRoundPeriod ? nowRoundId : null, + NextRound = isRoundPeriod ? null : nextRoundId, + StartTime = FormatTime(windowStart), + EndTime = FormatTime(windowEnd), + // Tutorial tip toggle — wire ints 0/1 per ColosseumEntryInfoTask.cs:129. + IsDisplayTips = season.IsDisplayTips ? 1 : 0, + TipsId = season.TipsId == 0 ? null : season.TipsId, IsAllCardEnabled = season.IsAllCardEnabled ? 1 : 0, SalesPeriodInfo = new ColosseumSalesPeriodInfo { @@ -43,6 +68,50 @@ public static class ColosseumLobbyInfoBuilder }; } + /// + /// Walks (ordered by RoundId) against + /// and returns the round window to emit on the wire. + /// + /// Inside a round → (true, round.RoundId, null, round.StartTime, round.EndTime) + /// Before any round → (false, null, firstRound.RoundId, firstRound.StartTime, firstRound.EndTime) + /// Between two rounds → (false, null, nextRound.RoundId, nextRound.StartTime, nextRound.EndTime) + /// After last round → (false, null, lastRound.RoundId, lastRound.StartTime, lastRound.EndTime) + /// — client renders "ended" UI; keeps the field non-null so the parser doesn't NRE. + /// No rounds configured → fall back to the event-level start/end window. Keeps the + /// wire well-formed even when rounds haven't been seeded yet. + /// + /// + private static (bool IsRoundPeriod, int? NowRoundId, int? NextRoundId, DateTime Start, DateTime End) + ResolveRoundWindow(ColosseumSeasonConfig season, ColosseumRoundsConfig rounds, DateTime nowUtc) + { + if (rounds.Rounds.Count == 0) + { + return (IsRoundPeriod: false, NowRoundId: null, NextRoundId: null, + Start: season.EventStartTime, End: season.EventEndTime); + } + + var ordered = rounds.Rounds.OrderBy(r => r.RoundId).ToList(); + + var current = ordered.FirstOrDefault(r => nowUtc >= r.StartTime && nowUtc <= r.EndTime); + if (current is not null) + { + return (IsRoundPeriod: true, NowRoundId: current.RoundId, NextRoundId: null, + Start: current.StartTime, End: current.EndTime); + } + + var upcoming = ordered.FirstOrDefault(r => r.StartTime > nowUtc); + if (upcoming is not null) + { + return (IsRoundPeriod: false, NowRoundId: null, NextRoundId: upcoming.RoundId, + Start: upcoming.StartTime, End: upcoming.EndTime); + } + + // Past last round — anchor to the last round's window so the wire stays well-formed. + var last = ordered[^1]; + return (IsRoundPeriod: false, NowRoundId: null, NextRoundId: last.RoundId, + Start: last.StartTime, End: last.EndTime); + } + private static string FormatTime(DateTime t) => t == default ? "" : t.ToString(WireDateFormat, CultureInfo.InvariantCulture); } diff --git a/SVSim.UnitTests/Models/Dtos/ArenaColosseum/ColosseumLobbyInfoBuilderTests.cs b/SVSim.UnitTests/Models/Dtos/ArenaColosseum/ColosseumLobbyInfoBuilderTests.cs new file mode 100644 index 00000000..9288d6c1 --- /dev/null +++ b/SVSim.UnitTests/Models/Dtos/ArenaColosseum/ColosseumLobbyInfoBuilderTests.cs @@ -0,0 +1,187 @@ +using System; +using NUnit.Framework; +using SVSim.Database.Enums; +using SVSim.Database.Models.Config; +using SVSim.EmulatedEntrypoint.Models.Dtos.ArenaColosseum; + +namespace SVSim.UnitTests.Models.Dtos.ArenaColosseum; + +/// +/// Pure-projection tests for the builder shared by /mypage/index, /arena_colosseum/top, +/// and /arena_colosseum/get_fee_info. Locks the wire-level round-window derivation per +/// Wizard/ColosseumEntryInfoTask.cs:111-126 plus the previously-missed +/// colosseum_id/is_display_tips/tips_id emissions. +/// +[TestFixture] +public class ColosseumLobbyInfoBuilderTests +{ + private static ColosseumSeasonConfig SeasonOn(int seasonId = 42) => new() + { + IsColosseumPeriod = true, + SeasonId = seasonId, + ColosseumName = "Test Cup", + DeckFormat = Format.Rotation, + EventStartTime = new DateTime(2026, 7, 1, 10, 0, 0, DateTimeKind.Utc), + EventEndTime = new DateTime(2026, 7, 31, 23, 59, 59, DateTimeKind.Utc), + SalesPeriodStart = new DateTime(2026, 7, 1, 10, 0, 0, DateTimeKind.Utc), + SalesPeriodEnd = new DateTime(2026, 7, 31, 23, 59, 59, DateTimeKind.Utc), + }; + + private static ColosseumRoundsConfig ThreeRounds() => new() + { + Rounds = new() + { + new ColosseumRoundsConfig.RoundEntry + { + RoundId = 1, + StartTime = new DateTime(2026, 7, 1, 10, 0, 0, DateTimeKind.Utc), + EndTime = new DateTime(2026, 7, 10, 23, 59, 59, DateTimeKind.Utc), + }, + new ColosseumRoundsConfig.RoundEntry + { + RoundId = 2, + StartTime = new DateTime(2026, 7, 11, 10, 0, 0, DateTimeKind.Utc), + EndTime = new DateTime(2026, 7, 20, 23, 59, 59, DateTimeKind.Utc), + }, + new ColosseumRoundsConfig.RoundEntry + { + RoundId = 3, + StartTime = new DateTime(2026, 7, 21, 10, 0, 0, DateTimeKind.Utc), + EndTime = new DateTime(2026, 7, 31, 23, 59, 59, DateTimeKind.Utc), + }, + }, + }; + + [Test] + public void Off_season_emits_only_is_colosseum_period_false() + { + var info = ColosseumLobbyInfoBuilder.Build( + new ColosseumSeasonConfig { IsColosseumPeriod = false }, + new ColosseumRoundsConfig(), + DateTime.UtcNow); + + Assert.That(info.IsColosseumPeriod, Is.False); + Assert.That(info.ColosseumName, Is.Null); + Assert.That(info.NowRound, Is.Null); + Assert.That(info.IsRoundPeriod, Is.Null); + Assert.That(info.ColosseumId, Is.Null); + Assert.That(info.IsDisplayTips, Is.Null); + } + + [Test] + public void Mid_round_2_emits_round_2_window_and_now_round_2() + { + var now = new DateTime(2026, 7, 15, 12, 0, 0, DateTimeKind.Utc); // inside Round 2 + + var info = ColosseumLobbyInfoBuilder.Build(SeasonOn(), ThreeRounds(), now); + + Assert.That(info.IsRoundPeriod, Is.True); + Assert.That(info.NowRound, Is.EqualTo(2)); + Assert.That(info.NextRound, Is.Null); + Assert.That(info.StartTime, Is.EqualTo("2026-07-11 10:00:00")); + Assert.That(info.EndTime, Is.EqualTo("2026-07-20 23:59:59")); + } + + [Test] + public void Between_rounds_emits_is_round_period_false_with_next_round() + { + var now = new DateTime(2026, 7, 11, 5, 0, 0, DateTimeKind.Utc); // after R1 end, before R2 start + + var info = ColosseumLobbyInfoBuilder.Build(SeasonOn(), ThreeRounds(), now); + + Assert.That(info.IsRoundPeriod, Is.False); + Assert.That(info.NowRound, Is.Null); + Assert.That(info.NextRound, Is.EqualTo(2), + "client renders 'Round 2 opens in HH:MM:SS' — needs the next round number"); + Assert.That(info.StartTime, Is.EqualTo("2026-07-11 10:00:00"), + "start_time is the next round's start so the client's countdown lands correctly"); + } + + [Test] + public void Pre_event_emits_round_1_as_next() + { + var now = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc); // before R1 starts + + var info = ColosseumLobbyInfoBuilder.Build(SeasonOn(), ThreeRounds(), now); + + Assert.That(info.IsRoundPeriod, Is.False); + Assert.That(info.NextRound, Is.EqualTo(1)); + Assert.That(info.StartTime, Is.EqualTo("2026-07-01 10:00:00")); + } + + [Test] + public void Post_event_anchors_to_last_round_window() + { + var now = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc); // after R3 end + + var info = ColosseumLobbyInfoBuilder.Build(SeasonOn(), ThreeRounds(), now); + + Assert.That(info.IsRoundPeriod, Is.False, + "no active round once the bracket has ended"); + Assert.That(info.NextRound, Is.EqualTo(3), + "anchored to last round so client doesn't NRE on the start_time/end_time read"); + Assert.That(info.EndTime, Is.EqualTo("2026-07-31 23:59:59")); + } + + [Test] + public void Empty_rounds_config_falls_back_to_event_window() + { + var now = new DateTime(2026, 7, 15, 12, 0, 0, DateTimeKind.Utc); + + var info = ColosseumLobbyInfoBuilder.Build(SeasonOn(), new ColosseumRoundsConfig(), now); + + Assert.That(info.IsRoundPeriod, Is.False); + Assert.That(info.NowRound, Is.Null); + Assert.That(info.StartTime, Is.EqualTo("2026-07-01 10:00:00"), + "event window is the sensible fallback when no rounds are configured"); + Assert.That(info.EndTime, Is.EqualTo("2026-07-31 23:59:59")); + } + + [Test] + public void Surfaces_colosseum_id_from_season_id() + { + var info = ColosseumLobbyInfoBuilder.Build( + SeasonOn(seasonId: 165), ThreeRounds(), + new DateTime(2026, 7, 5, 0, 0, 0, DateTimeKind.Utc)); + + Assert.That(info.ColosseumId, Is.EqualTo(165), + "wire colosseum_id mirrors SeasonId so the client can disambiguate cups"); + } + + [Test] + public void Surfaces_zero_season_id_as_absent() + { + var info = ColosseumLobbyInfoBuilder.Build( + SeasonOn(seasonId: 0), ThreeRounds(), + new DateTime(2026, 7, 5, 0, 0, 0, DateTimeKind.Utc)); + + Assert.That(info.ColosseumId, Is.Null, + "0 is the unconfigured default — strip so the field doesn't pollute the wire"); + } + + [Test] + public void Surfaces_is_display_tips_and_tips_id() + { + var season = SeasonOn(); + season.IsDisplayTips = true; + season.TipsId = 17; + + var info = ColosseumLobbyInfoBuilder.Build(season, ThreeRounds(), + new DateTime(2026, 7, 5, 0, 0, 0, DateTimeKind.Utc)); + + Assert.That(info.IsDisplayTips, Is.EqualTo(1), + "wire int per ColosseumEntryInfoTask.cs:129 (NeedsFirstTips = == 1)"); + Assert.That(info.TipsId, Is.EqualTo(17)); + } + + [Test] + public void Tips_default_off_with_zero_tips_id_stripped() + { + var info = ColosseumLobbyInfoBuilder.Build(SeasonOn(), ThreeRounds(), + new DateTime(2026, 7, 5, 0, 0, 0, DateTimeKind.Utc)); + + Assert.That(info.IsDisplayTips, Is.EqualTo(0), + "tips off by default — wire int 0 (client maps to false)"); + Assert.That(info.TipsId, Is.Null); + } +}