fix(arena-colosseum): tips + colosseum_id wiring + round-aware time derivation
Three fixups the Phase 1-3 work missed (would bite the first time an admin
flipped on a real event):
* IsDisplayTips + TipsId added to ColosseumSeasonConfig. Drives
Wizard/ColosseumEntryInfoTask.cs:129 (NeedsFirstTips = is_display_tips == 1).
The wire DTO already had the properties; the season config didn't carry
them, so admins had no way to gate the first-time tutorial tip popup.
* colosseum_id is now emitted from SeasonId. Wire field was always-absent
before (DTO had the property but builder never set it); client fell back
to 0 and couldn't disambiguate cups.
* ColosseumLobbyInfoBuilder.Build now takes ColosseumRoundsConfig + nowUtc
and derives the wire is_round_period / now_round / next_round / start_time
/ end_time from the rounds schedule against the current clock. Previous
hardcode (is_round_period: true, now_round: 1, event-window dates) would
have shown "Round 1 ends 2026-07-31" instead of the actual round's end.
Per the client parser at ColosseumEntryInfoTask.cs:111-126:
- inside a round → emit that round's window + is_round_period=true
- between rounds → emit next round's start as start_time + next_round
- pre-event → next_round=1, start_time=event start
- post-event → anchor to last round so the client doesn't NRE
- no rounds configured → fall back to event-level window
Why these were missed: I implemented ColosseumSeasonConfig from the plan's
explicit field list rather than cross-checking against the captured
colosseum.json wire shape; I added ColosseumId to the wire DTO without
wiring the builder side; and no test exercised a mid-event scenario with
realistic dates — Phase 2's E2E hand-edits run.RoundId via
PromoteRunToRoundAsync instead of letting the controller derive it.
* 10 new builder unit tests cover off-season / mid-round / between-rounds /
pre-event / post-event / empty-rounds-fallback + the new field surfacing.
Full suite: 1355/1355 (was 1345 + 10).
This commit is contained in:
@@ -58,6 +58,14 @@ public class ColosseumSeasonConfig
|
||||
/// <summary>Number of strategies offered per pick in 2-Pick Chaos mode.</summary>
|
||||
public int StrategyPickNum { get; set; }
|
||||
|
||||
/// <summary>Drives the client's first-time tutorial tip popup for this cup
|
||||
/// (<c>Wizard/ColosseumEntryInfoTask.cs:129</c>: <c>NeedsFirstTips = (is_display_tips == 1)</c>).
|
||||
/// Server emits wire int 0/1.</summary>
|
||||
public bool IsDisplayTips { get; set; }
|
||||
|
||||
/// <summary>Tutorial tip content id, paired with <see cref="IsDisplayTips"/>. Wire int.</summary>
|
||||
public int TipsId { get; set; }
|
||||
|
||||
public DateTime SalesPeriodStart { get; set; }
|
||||
public DateTime SalesPeriodEnd { get; set; }
|
||||
|
||||
|
||||
@@ -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<ColosseumRoundsConfig>(), DateTime.UtcNow);
|
||||
|
||||
/// <summary>Builds the <c>colosseum_status</c> block. When the viewer has no run, every
|
||||
/// property is null and global WhenWritingNull renders <c>{}</c> — the client
|
||||
|
||||
@@ -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<ColosseumRoundsConfig>(), DateTime.UtcNow),
|
||||
SealedInfo = BuildSealedInfo(sealedSeason),
|
||||
Banner = bannerEntries.Select(BuildBannerInfo).ToList(),
|
||||
RoomTypeInSession = new RoomTypeInSession
|
||||
|
||||
@@ -4,36 +4,61 @@ using SVSim.Database.Models.Config;
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.ArenaColosseum;
|
||||
|
||||
/// <summary>
|
||||
/// Single source of truth for projecting <see cref="ColosseumSeasonConfig"/> onto the wire
|
||||
/// <see cref="ColosseumLobbyInfo"/> block. Used by both <c>/arena_colosseum/{top,get_fee_info}</c>
|
||||
/// AND <c>/mypage/index data.colosseum_info</c> — the home-screen tab and the lobby reads
|
||||
/// must agree on the season state, so they go through one builder. When
|
||||
/// <c>IsColosseumPeriod = false</c> the minimal "no event" payload is emitted; the client
|
||||
/// Single source of truth for projecting <see cref="ColosseumSeasonConfig"/> +
|
||||
/// <see cref="ColosseumRoundsConfig"/> onto the wire <see cref="ColosseumLobbyInfo"/> block.
|
||||
/// Used by both <c>/arena_colosseum/{top,get_fee_info}</c> and
|
||||
/// <c>/mypage/index data.colosseum_info</c> — the home-screen tab and the lobby reads must
|
||||
/// agree on the season state, so they go through one builder.
|
||||
/// <para>
|
||||
/// When <c>IsColosseumPeriod = false</c> a minimal "no event" payload is emitted; the client
|
||||
/// gates every other field on that flag (<c>Wizard/ColosseumEntryInfoTask.cs:100</c>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Round-period derivation: <see cref="ColosseumRoundsConfig.Rounds"/> are walked against
|
||||
/// <paramref name="nowUtc"/>. If a round's window covers <paramref name="nowUtc"/>, we emit
|
||||
/// <c>is_round_period = true</c> + that round's window as <c>start_time/end_time</c> +
|
||||
/// <c>now_round</c>. Otherwise we emit <c>is_round_period = false</c> + 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 <c>ColosseumEntryInfoTask.cs:111-126</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks <see cref="ColosseumRoundsConfig.Rounds"/> (ordered by RoundId) against
|
||||
/// <paramref name="nowUtc"/> and returns the round window to emit on the wire.
|
||||
/// <list type="bullet">
|
||||
/// <item>Inside a round → <c>(true, round.RoundId, null, round.StartTime, round.EndTime)</c></item>
|
||||
/// <item>Before any round → <c>(false, null, firstRound.RoundId, firstRound.StartTime, firstRound.EndTime)</c></item>
|
||||
/// <item>Between two rounds → <c>(false, null, nextRound.RoundId, nextRound.StartTime, nextRound.EndTime)</c></item>
|
||||
/// <item>After last round → <c>(false, null, lastRound.RoundId, lastRound.StartTime, lastRound.EndTime)</c>
|
||||
/// — client renders "ended" UI; keeps the field non-null so the parser doesn't NRE.</item>
|
||||
/// <item>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.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>Wizard/ColosseumEntryInfoTask.cs:111-126</c> plus the previously-missed
|
||||
/// <c>colosseum_id</c>/<c>is_display_tips</c>/<c>tips_id</c> emissions.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user