UI/DailyChallengeScreen.razor
@using Sandbox
@using Sandbox.UI
@using System
@using System.Linq
@using System.Collections.Generic
@using System.Threading.Tasks
@inherits VirtualListPanel
@namespace BlockParty
@attribute [StyleSheet( "DailyChallengeScreen.razor.scss" )]
<root style="opacity: @(Stage?.FadeAlpha ?? 0f)">
@{ if ( Stage is null ) return; }
<PixelTooltip @ref="Tooltip"></PixelTooltip>
@* Quiet strip above the title (absolute, so appearing/expiring never shifts the layout). *@
@if ( DayCountdownText is string countdown )
{
<label class="countdown">@countdown</label>
}
<label class="header">DAILY CHALLENGE</label>
@* Day paging is driven by A/D (see the stage) and mouse clicks — the arrows aren't part of the
W/S selector cycle. *@
<div class="daybar">
<button class="square-btn nav i-arrow-left @(Stage.CanGoPrevious ? "" : "disabled")" onclick=@(() => Stage.PreviousDay())><div class="img"></div></button>
<div class="daytext">
<label class="date">@Stage.DisplayDate</label>
@if ( AttemptsText is string attempts )
{
<label class="attempts">@attempts</label>
}
</div>
<button class="square-btn nav i-arrow-right @(CanGoNext ? "" : "disabled")" onclick=@(() => Stage.NextDay())><div class="img"></div></button>
</div>
<div class="status">
@if ( DebugCanPlay )
{
@* daily_debug_nav: any non-today day plays its generated level — no submit/attempts,
local replay only (see GameStage.IsDebugRun). Today keeps the normal PLAY flow. *@
<div class="selcube @(StatusFocused ? "" : "hidden")" style="background-position: @(-SelectorFrame * 100)% 0%;"></div>
<button class="play debug @(StatusFocused ? "selected" : "")" onmouseover=@HoverStatus onclick=@(() => Stage.DebugPlay())>DEBUG PLAY</button>
}
else if ( Stage.IsFuture )
{
<label class="state">NOT AVAILABLE</label>
}
else if ( CanPlay )
{
<div class="selcube @(StatusFocused ? "" : "hidden")" style="background-position: @(-SelectorFrame * 100)% 0%;"></div>
<button class="play @(StatusFocused ? "selected" : "")" onmouseover=@HoverStatus onclick=@(() => Stage.Play())>PLAY</button>
}
else if ( HasReplay )
{
<div class="selcube @(StatusFocused ? "" : "hidden")" style="background-position: @(-SelectorFrame * 100)% 0%;"></div>
<button class="watch @(StatusFocused ? "selected" : "")" onmouseover=@HoverStatus onclick=@WatchMyRun>WATCH MY RUN</button>
}
else if ( Played )
{
<label class="state">PLAYED</label>
}
else if ( _loading )
{
<label class="state">CHECKING...</label>
}
else
{
<label class="state">NO RUN</label>
}
</div>
<div class="list">
@{
// Visible window + row placement come from VirtualListPanel.
int first = FirstVisible;
int last = LastVisible;
}
@if ( _loading )
{
<label class="info">LOADING...</label>
}
else if ( _rows.Count == 0 )
{
@* Past days' boards are closed — no "YET" about it. *@
<label class="info">@(Stage.IsToday || Stage.IsFuture ? "NO ENTRIES YET" : "NO ENTRIES")</label>
}
else
{
@for ( int i = first; i < last; i++ )
{
var row = _rows[i];
var rank = i + 1;
var idx = i;
var top = RowTop( i );
@* The row content is the shared LeaderboardRow; this outer div positions the row and
carries the zebra/selected background + hover-to-select. A launched replay returns
to this daily screen (ReturnDailyId). Debug rows pass their synthetic time inline. *@
<div class="row @(i % 2 == 1 ? "even" : "") @(RowFocused && i == Selected ? "selected" : "")" style="top: @(top)px;"
onmouseover=@(() => HoverRow( idx )) onclick=@(() => HoverRow( idx ))>
<LeaderboardRow Rank=@rank ShowRankTrophy=@true [email protected] [email protected] [email protected]
[email protected] [email protected] [email protected]
[email protected] DimNonVictoryScore=@true [email protected]
AwardTip=@StaleTipFactory WatchTip=@Tip( "Watch replay" )
StaleTip=@StaleTipFactory
ProfileTip=@Tip( "Steam profile" )></LeaderboardRow>
</div>
}
@if ( ShowScrollbar )
{
<div class="scrollbar" style="top: @(ThumbTop)px; height: @(ThumbHeight)px;"></div>
}
@* Hidden unless a leaderboard row is the focused zone; the other zones show their own cube. *@
@if ( RowFocused )
{
<div class="selector" style="top: @(SelectorTop)px; background-position: @(-SelectorFrame * 100)% 0%;"></div>
}
}
</div>
<div class="footer">
@* MAIN MENU is the bottom of the selector cycle: up-off the day-nav wraps here, down-off the
last row lands here. Its cube keeps a fixed lane so the button doesn't shift. *@
<div class="selcube @(MainMenuFocused ? "" : "hidden")" style="background-position: @(-SelectorFrame * 100)% 0%;"></div>
<button class="back @(MainMenuFocused ? "selected" : "")" onmouseover=@HoverMainMenu onclick=@GoToMainMenu>MAIN MENU</button>
</div>
@* Forced days override only the trigger preview and lock it; the player's persisted selection is
untouched and returns when browsing a non-forced day. The run itself reads ForcedCharacterId. *@
<div class="character-slot">
<CharacterPicker CenterOverlayOnScreen=@true
DisplayCharacterId=@(Stage.Daily?.Level.ForcedCharacterId)
TriggerLocked=@(Stage.Daily?.Level.ForcedCharacterId is not null)
LockedVisualsVisible=@_lockedPickerVisualsVisible
[email protected]></CharacterPicker>
</div>
@* Schematic of the day's generated level in the UI square's upper-left corner (mirrors the
character slot). Shown for today and every past day; future days only leak their layout with
daily_debug_nav on (generator evaluation). *@
@if ( ShowLevelPreview )
{
<div class="level-slot">
<LevelNodePreview [email protected]></LevelNodePreview>
</div>
}
<DailyTrophyStandingsPanel @ref="Trophies"></DailyTrophyStandingsPanel>
</root>
@code
{
public DailyChallengeStage Stage { get; set; }
private DailyTrophyStandingsPanel Trophies { get; set; }
private bool _wasToday;
private int _refreshGeneration;
public bool TrophyStandingsOpen => Trophies?.Expanded == true;
public void CloseTrophyStandings() => Trophies?.Close();
protected override void OnMouseWheel( Vector2 value )
{
if ( !TrophyStandingsOpen ) base.OnMouseWheel( value );
}
// Toggle: when true the board is filled with randomized synthetic rows instead of the live daily
// leaderboard, so the row layout (rank / name / time / block tally / score) can be eyeballed
// without a published board. Set false for the real board. (static readonly, not const, so
// flipping it never makes the other branch unreachable — a const bool would trip CS0162.)
private static readonly bool DEBUG_ROWS = false;
// ── hover tooltip for the rows' WATCH buttons ────────────────────────────────────────────────────
// The layer belongs to this SCREEN, not to LeaderboardRow: a per-row layer would be clipped by the
// list's scrolling container. Rows get the handler through their WatchTip parameter. Mechanism (and
// why the poll validates the stored panel rather than using mouseout) documented in LevelEditorHud.
private PixelTooltip Tooltip { get; set; }
private Panel _tipPanel;
private string _tipText;
private Action<PanelEvent> Tip( string text )
=> e => { _tipPanel = e.This; _tipText = text; };
// Explicitly typed: the engine's razor generator can't infer a lambda's delegate type inside an attribute.
private Func<string, Action<PanelEvent>> StaleTipFactory => reason => Tip( reason );
protected override void OnUpdate()
{
if ( TrophyStandingsOpen ) { Tooltip?.Hide(); return; }
base.OnUpdate();
if ( Stage is not null && _wasToday != Stage.IsToday )
{
_wasToday = Stage.IsToday;
// A hub held open across midnight must discard provisional local places too.
_ = Refresh();
}
if ( _tipPanel.IsValid() && _tipPanel.HasHovered && !string.IsNullOrEmpty( _tipText ) ) Tooltip?.Show( _tipText );
else Tooltip?.Hide();
PruneInconsistentRows();
}
// --- daily-board payload pruning (anti-tamper, display side) ------------------------------------
// Board writes are client-authoritative, so a patched client can put any value on today's board.
// The board query itself is day-filtered server-side (past-day submissions land in the wrong
// ingestion-date bucket and never come back — see Leaderboard.GetDailyBoard); this prunes what that
// can't: forged values submitted ON the day. Checks the visible window only — exactly the rows whose
// payloads the shared LeaderboardRow already fetches for its time column — so it costs no extra
// requests; an off-screen bad row is pruned when scrolled into view. A null payload (still loading,
// or fetch failed) is kept: it's indistinguishable from a network failure, and honest rows must not
// vanish offline. Payload-LESS entries (no DataUrl at all) are dropped in Refresh instead.
private void PruneInconsistentRows()
{
if ( DEBUG_ROWS || _loading || _rows.Count == 0 || Stage is null )
return;
int first = FirstVisible;
bool removed = false;
for ( int i = Math.Min( LastVisible, _rows.Count ) - 1; i >= first; i-- )
{
var row = _rows[i];
var data = RunDataStore.Get( row.DataUrl, OnPayloadLoaded );
if ( data is null || Leaderboard.IsDailyEntryConsistent( data, Stage.DailyId, row.Value ) )
continue;
if ( Game.IsEditor )
Log.Info( $"BlockParty: daily board {Stage.DailyId} hid row {i + 1} ({row.Name}, {row.SteamId}) — entry doesn't match its run payload." );
_rows.RemoveAt( i );
removed = true;
}
if ( removed )
{
ClampSelectionAfterRemoval();
StateHasChanged();
}
}
private List<LeaderboardRowData> _rows = new();
private bool _loading = true;
private bool _attemptedByBoard;
private long _localSteamId;
private int _dataVersion;
private bool _lockedPickerVisualsVisible = true;
public void BeginLockedPickerExit()
{
_lockedPickerVisualsVisible = false;
StateHasChanged();
}
/// <summary>True once the daily board has been fetched and the local player already has an entry
/// for this day. Exposed so <see cref="DailyChallengeStage.Play"/> can honour the same server-side
/// check the UI uses, rather than relying only on the local tamper-able attempt file.</summary>
public bool LocalPlayerOnBoard => _attemptedByBoard;
// --- selector focus zones -----------------------------------------------------------------
// The cube travels a single vertical cycle down the screen:
// [ PLAY/WATCH ] → [ leaderboard rows ] → [ MAIN MENU ] → (wraps)
// The PLAY/WATCH slot only joins the cycle when it's an actual button (StatusSelectable). Rows
// reuse the base's Selected/scroll; the button zones render their own cube (see the .selcube
// elements) off the shared SelectorFrame. Day paging is A/D-only and not part of this cycle.
private enum Zone { Status, Row, MainMenu }
private Zone _zone = Zone.MainMenu;
private readonly NavRepeater _navRepeat = new();
// PLAY/WATCH is a selectable button (vs. a plain status label) only in these states.
private bool StatusSelectable => CanPlay || HasReplay || DebugCanPlay;
// daily_debug_nav: non-today days offer DEBUG PLAY (no submit/attempts, local replay only).
private bool DebugCanPlay => DailyChallengeStage.DebugNavigation && Stage?.IsToday == false;
private bool StatusFocused => _zone == Zone.Status;
private bool RowFocused => _zone == Zone.Row;
private bool MainMenuFocused => _zone == Zone.MainMenu;
// Row count for the virtual-list base (scroll/selection/selector all live in VirtualListPanel).
protected override int ItemCount => _rows.Count;
// This board's list panel is shorter than the full-screen leaderboards; seed the pre-layout
// viewport to match so the first frame's row count is unchanged from before the refactor.
protected override float InitialViewportHeight => 520f;
private bool CanGoNext => Stage is not null
&& (DailyChallengeStage.DebugNavigation || !DailyChallenge.IsFuture( DailyChallenge.AddDays( Stage.DailyId, 1 ) ));
private bool LocalAttempted => Stage is not null && DailyChallengeProgress.IsAttempted( Stage.DailyId );
// Display-only ("has a score / has played") — no longer a lockout by itself on multi-attempt days.
private bool Played => LocalAttempted || _attemptedByBoard;
// The local attempt counter gates every day; the board additionally locks SINGLE-attempt days
// (authoritative — survives deleting the local file). See DailyChallengeStage.Play.
private bool AttemptsRemain => Stage?.Daily is DailyDef daily
&& DailyChallengeProgress.HasAttemptsRemaining( Stage.DailyId, daily.MaxAttempts )
&& !(daily.MaxAttempts == 1 && Played);
private bool CanPlay => Stage?.IsToday == true && !_loading && AttemptsRemain;
// --- day-end countdown ---------------------------------------------------------------------
// "DAY ENDS IN M:SS" under the date as TODAY approaches UTC midnight: a run started this close
// to the rollover may finish (or flush) after the day's board window closes and miss the board
// (see Leaderboard.GetDailyBoard). Display only — PLAY is never disabled by this.
// TUNING: show the countdown all day instead of only inside the final window, to compare both
// presentations. (static readonly, not const, for the same CS0162 reason as DEBUG_ROWS.)
private static readonly bool ALWAYS_SHOW_DAY_COUNTDOWN = true;
// How close to UTC midnight the countdown appears (ignored when ALWAYS_SHOW_DAY_COUNTDOWN).
private const double CountdownWindowMinutes = 10;
private string DayCountdownText
{
get
{
if ( Stage?.IsToday != true )
return null;
var left = DailyChallenge.TimeUntilDayEnd;
if ( !ALWAYS_SHOW_DAY_COUNTDOWN && left.TotalMinutes > CountdownWindowMinutes )
return null;
return left.TotalHours >= 1
? $"DAY ENDS IN {(int)left.TotalHours}:{left.Minutes:00}:{left.Seconds:00}"
: $"DAY ENDS IN {left.Minutes}:{left.Seconds:00}";
}
}
// Per-day attempt info under the date. Today counts down what's left; past days show what was
// spent (or just the day's allowance if it was never played); no-limit days say UNLIMITED.
// Uses DisplayedAttemptsUsed so daily_debug_nav runs exercise this label without real progress.
private string AttemptsText
{
get
{
if ( Stage?.Daily is not DailyDef daily ) return null;
// Future days only leak their generated limit with daily_debug_nav on (like the preview).
if ( Stage.IsFuture && !DailyChallengeStage.DebugNavigation ) return null;
if ( daily.MaxAttempts is not int max ) return "UNLIMITED ATTEMPTS";
int used = Math.Clamp( DailyChallengeProgress.DisplayedAttemptsUsed( Stage.DailyId ), 0, max );
if ( Stage.IsToday )
return used >= max ? $"{max}/{max} ATTEMPTS USED"
: $"{max - used}/{max} ATTEMPTS LEFT";
if ( used > 0 ) return $"{used}/{max} ATTEMPTS USED";
return max == 1 ? "1 ATTEMPT" : $"{max} ATTEMPTS";
}
}
private bool ShowLevelPreview => Stage is not null
&& (!Stage.IsFuture || DailyChallengeStage.DebugNavigation)
&& Stage.Daily?.Level is not null;
private RunData LocalReplay => Stage is null ? null : DailyChallengeProgress.GetReplay( Stage.DailyId );
private LeaderboardRowData? MyBoardRow
{
get
{
int index = _rows.FindIndex( r => r.SteamId == _localSteamId );
return index >= 0 ? _rows[index] : null;
}
}
private RunData BoardReplay => MyBoardRow is LeaderboardRowData row && !string.IsNullOrEmpty( row.DataUrl ) ? RunDataStore.Get( row.DataUrl, OnPayloadLoaded ) : null;
private bool HasReplay => LocalReplay?.CanReplay == true || BoardReplay?.CanReplay == true;
protected override void OnStart()
{
base.OnStart();
_localSteamId = (long)Game.SteamId;
_wasToday = Stage?.IsToday == true;
_ = Refresh();
}
private async Task Refresh()
{
int generation = ++_refreshGeneration;
_loading = true;
_attemptedByBoard = false;
StateHasChanged();
// DEBUG: skip the live board and show randomized synthetic rows for layout checking.
if ( DEBUG_ROWS )
{
_rows = BuildDebugRows( _localSteamId );
_attemptedByBoard = _rows.Any( r => r.SteamId == _localSteamId );
_loading = false;
SelectDefault();
StateHasChanged();
return;
}
// Limited-attempt day: pull the backend attempt counter before enabling PLAY, so a deleted
// local progress file can't re-open the day (best-effort; offline falls back to the file).
if ( Stage.IsToday && Stage.Daily?.MaxAttempts is not null )
await DailyChallengeProgress.RefreshBackendAttempts( Stage.DailyId );
if ( !this.IsValid() || generation != _refreshGeneration ) return;
string statName = Leaderboard.DailyStatName( Stage.DailyId );
double? localScore = null;
try
{
var board = Leaderboard.GetDailyBoard( Stage.DailyId );
board.IncludeSteamIds( _localSteamId );
await board.Refresh();
if ( !this.IsValid() || generation != _refreshGeneration ) return;
var entries = board.Entries?.ToList() ?? new();
localScore = entries
.Where( entry => entry.SteamId == _localSteamId )
.Select( entry => (double?)entry.Value )
.FirstOrDefault();
Leaderboard.RecordPersonalBest( statName, localScore );
// Read straight off the backend's own entries, NOT off _rows. _rows later gains a locally
// spliced row (see below) and this flag gates the single-attempt lockout in
// DailyChallengeStage.Play — it has to mean "the server says I'm on this day's board" and
// nothing else, whatever the display list ends up containing.
_attemptedByBoard = entries.Any( e => e.SteamId == _localSteamId );
// IncludeSteamIds keeps attempt detection authoritative for players below the top 100;
// only ranked head entries belong in the displayed leaderboard.
entries = await LeaderboardOrder.CompletePodiumTiesAsync(
entries.Where( e => e.Rank <= Leaderboard.MaxEntries ).ToList(), board.TotalEntries,
() => Leaderboard.GetDailyBoard( Stage.DailyId ),
isCurrent: () => this.IsValid() && generation == _refreshGeneration );
if ( !this.IsValid() || generation != _refreshGeneration ) return;
_rows = entries.Take( Leaderboard.MaxEntries ).Select( e => new LeaderboardRowData
{
SteamId = e.SteamId,
Name = e.DisplayName,
Score = (int)e.Value,
Value = e.Value,
DataUrl = e.DataUrl,
CountryCode = e.CountryCode,
Timestamp = e.Timestamp,
TimeSeconds = float.NaN, // resolved lazily from the data payload on render
} ).ToList();
if ( Game.IsEditor )
Log.Info( $"BlockParty: daily leaderboard {Stage.DailyId} → {_rows.Count} entries [querying ident '{Sandbox.Game.Ident}' stat '{statName}']." );
// Every legitimate daily submission attaches its run payload (Leaderboard.Submit always
// passes data:), so an entry with no DataUrl is a bare forged Stats.SetValue — hide it.
// Entries whose payload exists but disagrees are pruned once it loads, in
// PruneInconsistentRows. Deliberately AFTER _attemptedByBoard is read: a forged write under
// your own SteamId still counts as "on the board" for the 1-attempt lockout.
int noData = _rows.RemoveAll( r => string.IsNullOrEmpty( r.DataUrl ) );
if ( Game.IsEditor && noData > 0 )
Log.Info( $"BlockParty: daily board {Stage.DailyId} hid {noData} payload-less entries." );
}
catch ( Exception e )
{
if ( !this.IsValid() || generation != _refreshGeneration ) return;
if ( Game.IsEditor )
Log.Info( $"BlockParty: daily leaderboard {Stage.DailyId} empty/not-ready ({e.Message}) [querying ident '{Sandbox.Game.Ident}' stat '{statName}']." );
}
if ( !this.IsValid() || generation != _refreshGeneration )
return;
// Show today's run straight away even while the board still serves a pre-submit cached
// response. Display only — _attemptedByBoard above is deliberately left out of this.
LeaderboardRows.SpliceLocalBest( _rows, statName, Leaderboard.MaxEntries, localScore, allowLocalPending: Stage.IsToday );
_loading = false;
SelectDefault();
StateHasChanged();
}
private void OnPayloadLoaded()
{
if ( !this.IsValid() ) return;
_dataVersion++;
StateHasChanged();
}
// --- zone navigation --------------------------------------------------------------------
// W/S vertical movement (with hold-to-repeat) drives the whole-screen focus cycle, replacing the
// base's row-only navigation.
protected override void HandleNavigation( float dt )
{
EnsureValidZone();
int step = _navRepeat.Tick( dt,
Input.Down( "Up" ) || Input.Down( "UpArrow" ),
Input.Down( "Down" ) || Input.Down( "DownArrow" ) );
if ( step != 0 )
MoveFocus( step );
}
// The zones flattened to a vertical index: PLAY/WATCH (if selectable) first, then the rows, then
// MAIN MENU last. Header count is the number of slots above the rows.
private int HeaderCount => StatusSelectable ? 1 : 0;
private int CurrentLinear() => _zone switch
{
Zone.Status => 0,
Zone.Row => HeaderCount + Math.Clamp( Selected, 0, Math.Max( 0, ItemCount - 1 ) ),
_ => HeaderCount + ItemCount, // MainMenu
};
private void MoveFocus( int dir )
{
int total = HeaderCount + ItemCount + 1; // (status) + rows + main menu
int cur = CurrentLinear();
int next = ((cur + dir) % total + total) % total;
// Only MAIN MENU is present (empty, no PLAY/WATCH) — nowhere to move, so don't fake a move blip.
if ( next == cur )
return;
if ( StatusSelectable && next == 0 )
SetButtonZone( Zone.Status );
else if ( next < HeaderCount + ItemCount )
SetRowZone( next - HeaderCount );
else
SetButtonZone( Zone.MainMenu );
}
private void SetButtonZone( Zone zone )
{
_zone = zone;
PlaySelectorMove();
Audio.PlaySfx( SfxType.MenuBlip );
StateHasChanged();
}
private void SetRowZone( int index )
{
_zone = Zone.Row;
FocusRow( index );
Audio.PlaySfx( SfxType.MenuBlip, 1f, RowBlipPitch( index ) );
StateHasChanged();
}
// Focus can't sit on a zone that isn't currently shown (the PLAY/WATCH button can come and go as
// the board/replay data resolves; the list can be empty). Fall back to MAIN MENU if so.
private void EnsureValidZone()
{
if ( _zone == Zone.Status && !StatusSelectable )
_zone = Zone.MainMenu;
else if ( _zone == Zone.Row && ItemCount == 0 )
_zone = Zone.MainMenu;
}
// Confirm/Space activates whatever's focused. Routed here from the stage's Confirm edge.
public void ActivateFocused()
{
if ( TrophyStandingsOpen ) return;
switch ( _zone )
{
case Zone.Status:
if ( DebugCanPlay ) Stage.DebugPlay();
else if ( CanPlay ) Stage.Play();
else if ( HasReplay ) WatchMyRun();
else WobbleSelector();
break;
case Zone.Row:
LaunchSelected();
break;
default: // MainMenu
GoToMainMenu();
break;
}
}
private void GoToMainMenu()
{
Audio.PlaySfx( SfxType.MenuStart );
Stage.GoBack();
}
// --- mouse focus (hover mirrors the keyboard focus) -------------------------------------
private void HoverRow( int index )
{
_zone = Zone.Row;
SelectRow( index );
StateHasChanged();
}
private void HoverStatus()
{
if ( _zone == Zone.Status ) return;
_zone = Zone.Status;
PlaySelectorMove();
StateHasChanged();
}
private void HoverMainMenu()
{
if ( _zone == Zone.MainMenu ) return;
_zone = Zone.MainMenu;
PlaySelectorMove();
StateHasChanged();
}
private void WatchMyRun()
{
var run = LocalReplay;
if ( run?.CanReplay == true )
{
var attempt = DailyChallengeProgress.Get( Stage.DailyId );
var when = attempt?.CompletedAt ?? attempt?.StartedAt ?? DateTimeOffset.Now;
var friend = new Friend( _localSteamId );
GameManager.Instance?.StartReplay( run, new ReplaySubmitter( _localSteamId, friend.Name, "", when ), returnDailyId: Stage.DailyId );
return;
}
if ( MyBoardRow is LeaderboardRowData row )
{
var payload = string.IsNullOrEmpty( row.DataUrl ) ? null : RunDataStore.Get( row.DataUrl, OnPayloadLoaded );
if ( payload?.CanReplay == true )
StartReplay( row, payload );
}
}
private void StartReplay( LeaderboardRowData row, RunData data )
{
GameManager.Instance?.StartReplay( data, new ReplaySubmitter( row.SteamId, row.Name, row.CountryCode, row.Timestamp ), returnDailyId: Stage?.DailyId );
}
public void LaunchSelected()
{
if ( Selected < 0 || Selected >= _rows.Count ) return;
var row = _rows[Selected];
var payload = string.IsNullOrEmpty( row.DataUrl ) ? null : RunDataStore.Get( row.DataUrl, OnPayloadLoaded );
if ( payload?.CanReplay == true ) StartReplay( row, payload );
else WobbleSelector();
}
// Open focused on the primary action — PLAY/WATCH when it's an actual button — else on MAIN MENU
// (a safe default when there's nothing to play/watch). Keep the base selection parked on the local
// player's row so entering the list lands there.
private void SelectDefault()
{
int me = _rows.FindIndex( r => r.SteamId == _localSteamId );
SelectInitial( me >= 0 ? me : 0 );
_zone = StatusSelectable ? Zone.Status : Zone.MainMenu;
}
// DEBUG (see DEBUG_ROWS): a board of randomized synthetic rows so the row layout — rank, avatar,
// name (varied widths/scripts), time, block tally, score, and the "me" highlight — can be
// eyeballed without a live daily board. Each row's synthetic payload (time + packed block states)
// is primed into RunDataStore under a fake url, so the shared LeaderboardRow resolves it through
// exactly the same path as a real row. The payloads carry no recorded input, so CanReplay stays
// false and no ▶ button appears. Sorted by score descending like the live board.
private static List<LeaderboardRowData> BuildDebugRows( long meId )
{
var rng = new System.Random();
string[] names =
{
"PLAYER", "xX_LongDragonSlayer_ProGamer_Xx", "日本語のなまえ", "مرحبا باللعبة",
"Игрок Кириллица", "🎮🔥 GamerXX 🐉", "shortie", "THE_NAME_THAT_KEEPS_GOING_ON_FOREVER",
};
int count = rng.Next( 25, 60 );
var rows = new List<LeaderboardRowData>( count );
for ( int i = 0; i < count; i++ )
{
int score = rng.Next( 100, 2000 );
float seconds = rng.Next( 20, 900 ) + (float)rng.NextDouble();
string dataUrl = $"debug-run://daily/{i}";
RunDataStore.Prime( dataUrl, new RunData { TimeSeconds = seconds, FinalScore = score, Blocks = RandomBlocks( rng ) } );
rows.Add( new LeaderboardRowData
{
SteamId = 76561197960265728L + i,
Name = $"{names[rng.Next( names.Length )]} {i:00}",
Score = score,
Value = score,
DataUrl = dataUrl,
TimeSeconds = seconds,
} );
}
rows = rows.OrderByDescending( r => r.Score ).ToList();
// Mark one row as the local player to preview the "me" highlight / PLAYED state.
if ( rows.Count > 3 )
{
var me = rows[3];
me.SteamId = meId;
me.Name = "YOU (DEBUG)";
rows[3] = me;
}
return rows;
}
// DEBUG: one packed tally int per block type, each with a random phase and random pressed sides,
// in the same (type << 6) | (phase << 4) | sides layout RunData.PackBlocks/DecodeBlocks use.
private static List<int> RandomBlocks( System.Random rng )
{
var blocks = new List<int>();
foreach ( var type in Enum.GetValues<BlockType>() )
{
int phase = rng.Next( 0, Block.NUM_PHASES ); // 0..2
int sides = rng.Next( 0, 16 ); // 4 bits: L R U D
blocks.Add( ((int)type << 6) | (phase << 4) | sides );
}
return blocks;
}
protected override int BuildHash() => HashCode.Combine(
_loading,
_attemptedByBoard,
LocalAttempted,
_rows.Count,
_dataVersion,
Selected,
HashCode.Combine( (int)ScrollPos, (int)(SelectorPos * 8f), SelectorFrame, Stage?.DailyId ?? "" ),
HashCode.Combine( (int)_zone, DailyChallengeProgress.DisplayedAttemptsUsed( Stage?.DailyId ), DailyChallengeStage.DebugNavigation,
// Whole seconds to the UTC rollover, so the day-end countdown ticks (and appears/expires).
(int)DailyChallenge.TimeUntilDayEnd.TotalSeconds ) );
}