using System.Globalization; 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 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, 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 = isRoundPeriod, IsSpecialMode = season.IsSpecialMode, CardPoolName = string.IsNullOrEmpty(season.CardPoolName) ? null : season.CardPoolName, 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 { SalesPeriodTime = FormatTime(season.SalesPeriodEnd), }, StrategyPickNum = season.StrategyPickNum > 0 ? season.StrategyPickNum : null, }; } /// /// 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); }