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);
+ }
+}