From 5b99f898601b07dc670c20135f4e8f846747c9d9 Mon Sep 17 00:00:00 2001 From: Patchzy <64382339+patchzyy@users.noreply.github.com> Date: Wed, 18 Feb 2026 00:29:19 +0100 Subject: [PATCH 1/8] Add leaderboard page and podium card component --- .../RrRooms/Domain/RwfcLeaderboardEntry.cs | 2 +- WheelWizard/Views/App.axaml | 3 +- .../WhWzLibrary/LeaderboardPodiumCard.axaml | 468 +++++++++++++++++ .../LeaderboardPodiumCard.axaml.cs | 100 ++++ WheelWizard/Views/Layout.axaml | 3 + WheelWizard/Views/Pages/LeaderboardPage.axaml | 247 +++++++++ .../Views/Pages/LeaderboardPage.axaml.cs | 485 ++++++++++++++++++ .../Views/Pages/LeaderboardPlayerItem.cs | 26 + 8 files changed, 1332 insertions(+), 2 deletions(-) create mode 100644 WheelWizard/Views/Components/WhWzLibrary/LeaderboardPodiumCard.axaml create mode 100644 WheelWizard/Views/Components/WhWzLibrary/LeaderboardPodiumCard.axaml.cs create mode 100644 WheelWizard/Views/Pages/LeaderboardPage.axaml create mode 100644 WheelWizard/Views/Pages/LeaderboardPage.axaml.cs create mode 100644 WheelWizard/Views/Pages/LeaderboardPlayerItem.cs diff --git a/WheelWizard/Features/RrRooms/Domain/RwfcLeaderboardEntry.cs b/WheelWizard/Features/RrRooms/Domain/RwfcLeaderboardEntry.cs index c72a4fcf..dae9630a 100644 --- a/WheelWizard/Features/RrRooms/Domain/RwfcLeaderboardEntry.cs +++ b/WheelWizard/Features/RrRooms/Domain/RwfcLeaderboardEntry.cs @@ -16,5 +16,5 @@ public sealed class RwfcLeaderboardEntry public RwfcLeaderboardVrStats? VrStats { get; set; } - public string? MiiImageBase64 { get; set; } + public string? MiiData { get; set; } } diff --git a/WheelWizard/Views/App.axaml b/WheelWizard/Views/App.axaml index 65f76ef6..f7afb145 100644 --- a/WheelWizard/Views/App.axaml +++ b/WheelWizard/Views/App.axaml @@ -70,5 +70,6 @@ + - \ No newline at end of file + diff --git a/WheelWizard/Views/Components/WhWzLibrary/LeaderboardPodiumCard.axaml b/WheelWizard/Views/Components/WhWzLibrary/LeaderboardPodiumCard.axaml new file mode 100644 index 00000000..64104b03 --- /dev/null +++ b/WheelWizard/Views/Components/WhWzLibrary/LeaderboardPodiumCard.axaml @@ -0,0 +1,468 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WheelWizard/Views/Components/WhWzLibrary/LeaderboardPodiumCard.axaml.cs b/WheelWizard/Views/Components/WhWzLibrary/LeaderboardPodiumCard.axaml.cs new file mode 100644 index 00000000..d07c1096 --- /dev/null +++ b/WheelWizard/Views/Components/WhWzLibrary/LeaderboardPodiumCard.axaml.cs @@ -0,0 +1,100 @@ +using Avalonia; +using Avalonia.Controls.Primitives; +using WheelWizard.WheelWizardData.Domain; +using WheelWizard.WiiManagement.MiiManagement.Domain.Mii; + +namespace WheelWizard.Views.Components; + +public class LeaderboardPodiumCard : TemplatedControl +{ + public static readonly StyledProperty RankProperty = AvaloniaProperty.Register(nameof(Rank)); + + public int Rank + { + get => GetValue(RankProperty); + set => SetValue(RankProperty, value); + } + + public static readonly StyledProperty PlacementLabelProperty = AvaloniaProperty.Register( + nameof(PlacementLabel), + string.Empty + ); + + public string PlacementLabel + { + get => GetValue(PlacementLabelProperty); + set => SetValue(PlacementLabelProperty, value); + } + + public static readonly StyledProperty PlayerNameProperty = AvaloniaProperty.Register( + nameof(PlayerName), + string.Empty + ); + + public string PlayerName + { + get => GetValue(PlayerNameProperty); + set => SetValue(PlayerNameProperty, value); + } + + public static readonly StyledProperty VrTextProperty = AvaloniaProperty.Register( + nameof(VrText), + "--" + ); + + public string VrText + { + get => GetValue(VrTextProperty); + set => SetValue(VrTextProperty, value); + } + + public static readonly StyledProperty MiiProperty = AvaloniaProperty.Register(nameof(Mii)); + + public Mii? Mii + { + get => GetValue(MiiProperty); + set => SetValue(MiiProperty, value); + } + + public static readonly StyledProperty AvatarSizeProperty = AvaloniaProperty.Register( + nameof(AvatarSize), + 104 + ); + + public double AvatarSize + { + get => GetValue(AvatarSizeProperty); + set => SetValue(AvatarSizeProperty, value); + } + + public static readonly StyledProperty BadgeVariantProperty = AvaloniaProperty.Register< + LeaderboardPodiumCard, + BadgeVariant + >(nameof(BadgeVariant), BadgeVariant.None); + + public BadgeVariant BadgeVariant + { + get => GetValue(BadgeVariantProperty); + set => SetValue(BadgeVariantProperty, value); + } + + public static readonly StyledProperty ShowBadgeProperty = AvaloniaProperty.Register( + nameof(ShowBadge) + ); + + public bool ShowBadge + { + get => GetValue(ShowBadgeProperty); + set => SetValue(ShowBadgeProperty, value); + } + + public static readonly StyledProperty IsSuspiciousProperty = AvaloniaProperty.Register( + nameof(IsSuspicious) + ); + + public bool IsSuspicious + { + get => GetValue(IsSuspiciousProperty); + set => SetValue(IsSuspiciousProperty, value); + } +} diff --git a/WheelWizard/Views/Layout.axaml b/WheelWizard/Views/Layout.axaml index e0512497..c91c38ee 100644 --- a/WheelWizard/Views/Layout.axaml +++ b/WheelWizard/Views/Layout.axaml @@ -134,6 +134,9 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WheelWizard/Views/Pages/LeaderboardPage.axaml.cs b/WheelWizard/Views/Pages/LeaderboardPage.axaml.cs new file mode 100644 index 00000000..f7b9b363 --- /dev/null +++ b/WheelWizard/Views/Pages/LeaderboardPage.axaml.cs @@ -0,0 +1,485 @@ +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using Avalonia.Controls; +using Avalonia.Interactivity; +using WheelWizard.Models; +using WheelWizard.Resources.Languages; +using WheelWizard.RrRooms; +using WheelWizard.Services.Settings; +using WheelWizard.Shared.DependencyInjection; +using WheelWizard.Utilities.Generators; +using WheelWizard.Views.Popups; +using WheelWizard.Views.Popups.MiiManagement; +using WheelWizard.WheelWizardData; +using WheelWizard.WheelWizardData.Domain; +using WheelWizard.WiiManagement.GameLicense; +using WheelWizard.WiiManagement.MiiManagement; +using WheelWizard.WiiManagement.MiiManagement.Domain.Mii; + +namespace WheelWizard.Views.Pages; + +public partial class LeaderboardPage : UserControlBase, INotifyPropertyChanged +{ + private CancellationTokenSource? _loadCts; + + [Inject] + private IRrLeaderboardSingletonService LeaderboardService { get; set; } = null!; + + [Inject] + private IWhWzDataSingletonService BadgeService { get; set; } = null!; + + [Inject] + private IGameLicenseSingletonService GameDataService { get; set; } = null!; + + private bool _hasLoadedOnce; + private bool _hasError; + private bool _hasNoData; + private bool _hasData; + private string _errorMessage = string.Empty; + private int _loadedPlayerCount; + private LeaderboardPlayerItem? _podiumFirst; + private LeaderboardPlayerItem? _podiumSecond; + private LeaderboardPlayerItem? _podiumThird; + + public ObservableCollection RemainingPlayers { get; } = []; + + public bool HasError + { + get => _hasError; + private set + { + if (_hasError == value) + return; + _hasError = value; + OnPropertyChanged(nameof(HasError)); + } + } + + public bool HasNoData + { + get => _hasNoData; + private set + { + if (_hasNoData == value) + return; + _hasNoData = value; + OnPropertyChanged(nameof(HasNoData)); + } + } + + public bool HasData + { + get => _hasData; + private set + { + if (_hasData == value) + return; + _hasData = value; + OnPropertyChanged(nameof(HasData)); + } + } + + public string ErrorMessage + { + get => _errorMessage; + private set + { + if (_errorMessage == value) + return; + _errorMessage = value; + OnPropertyChanged(nameof(ErrorMessage)); + } + } + + public string TotalPlayerCountText => _loadedPlayerCount.ToString(); + + public string RemainingCountText => $"{RemainingPlayers.Count} players"; + + public LeaderboardPlayerItem? PodiumFirst + { + get => _podiumFirst; + private set + { + if (_podiumFirst == value) + return; + _podiumFirst = value; + OnPropertyChanged(nameof(PodiumFirst)); + OnPropertyChanged(nameof(HasPodiumFirst)); + } + } + + public LeaderboardPlayerItem? PodiumSecond + { + get => _podiumSecond; + private set + { + if (_podiumSecond == value) + return; + _podiumSecond = value; + OnPropertyChanged(nameof(PodiumSecond)); + OnPropertyChanged(nameof(HasPodiumSecond)); + } + } + + public LeaderboardPlayerItem? PodiumThird + { + get => _podiumThird; + private set + { + if (_podiumThird == value) + return; + _podiumThird = value; + OnPropertyChanged(nameof(PodiumThird)); + OnPropertyChanged(nameof(HasPodiumThird)); + } + } + + public bool HasPodiumFirst => PodiumFirst != null; + public bool HasPodiumSecond => PodiumSecond != null; + public bool HasPodiumThird => PodiumThird != null; + + public LeaderboardPage() + { + InitializeComponent(); + DataContext = this; + RemainingPlayers.CollectionChanged += RemainingPlayers_OnCollectionChanged; + + Loaded += LeaderboardPage_Loaded; + Unloaded += LeaderboardPage_Unloaded; + } + + private async void LeaderboardPage_Loaded(object? sender, RoutedEventArgs e) + { + if (_hasLoadedOnce) + return; + + _hasLoadedOnce = true; + await ReloadLeaderboardAsync(); + } + + private void LeaderboardPage_Unloaded(object? sender, RoutedEventArgs e) + { + CancelCurrentLoad(); + } + + private void RemainingPlayers_OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + OnPropertyChanged(nameof(RemainingCountText)); + } + + private async void RetryButton_OnClick(object? sender, RoutedEventArgs e) + { + await ReloadLeaderboardAsync(); + } + + private async Task ReloadLeaderboardAsync() + { + CancelCurrentLoad(); + _loadCts = new(); + var cancellationToken = _loadCts.Token; + + SetLoadingState(); + ClearLeaderboardData(); + + var leaderboardResult = await LeaderboardService.GetTopPlayersAsync(50); + if (cancellationToken.IsCancellationRequested) + return; + + if (leaderboardResult.IsFailure) + { + SetErrorState(leaderboardResult.Error?.Message ?? "Unable to fetch leaderboard."); + return; + } + + var orderedEntries = leaderboardResult + .Value.Select((entry, index) => new { Entry = entry, Rank = ResolveRank(entry, index) }) + .OrderBy(entry => entry.Rank) + .Take(50) + .ToList(); + + if (orderedEntries.Count == 0) + { + SetEmptyState(); + return; + } + + var mappedPlayers = orderedEntries.Select((entry, index) => CreateLeaderboardPlayer(entry.Entry, entry.Rank, index)).ToList(); + + _loadedPlayerCount = mappedPlayers.Count; + OnPropertyChanged(nameof(TotalPlayerCountText)); + + PodiumFirst = mappedPlayers.ElementAtOrDefault(0); + PodiumSecond = mappedPlayers.ElementAtOrDefault(1); + PodiumThird = mappedPlayers.ElementAtOrDefault(2); + + foreach (var player in mappedPlayers.Skip(3)) + { + if (cancellationToken.IsCancellationRequested) + return; + + RemainingPlayers.Add(player); + + try + { + await Task.Delay(12, cancellationToken); + } + catch (OperationCanceledException) + { + return; + } + } + + SetDataState(); + } + + private LeaderboardPlayerItem CreateLeaderboardPlayer(RwfcLeaderboardEntry entry, int rank, int index) + { + var friendCode = entry.FriendCode ?? string.Empty; + var badges = string.IsNullOrWhiteSpace(friendCode) ? [] : BadgeService.GetBadges(friendCode); + var primaryBadge = badges.FirstOrDefault(BadgeVariant.None); + + return new() + { + Rank = rank, + PlacementLabel = GetPlacementLabel(rank), + Name = string.IsNullOrWhiteSpace(entry.Name) ? "Unknown Player" : entry.Name, + FriendCode = friendCode, + VrText = entry.Vr?.ToString("N0") ?? "--", + Mii = DeserializeMii(entry.MiiData), + PrimaryBadge = primaryBadge, + HasBadge = primaryBadge != BadgeVariant.None, + IsSuspicious = entry.IsSuspicious, + IsEvenRow = index % 2 == 0, + }; + } + + private static int ResolveRank(RwfcLeaderboardEntry entry, int index) + { + if (entry.Rank is > 0 and <= 50000) + return entry.Rank.Value; + + if (entry.ActiveRank is > 0 and <= 50000) + return entry.ActiveRank.Value; + + return index + 1; + } + + private static string GetPlacementLabel(int rank) => + rank switch + { + 1 => "Champion", + 2 => "2nd Place", + 3 => "3rd Place", + _ => $"#{rank}", + }; + + private static Mii? DeserializeMii(string? miiData) + { + if (string.IsNullOrWhiteSpace(miiData)) + return null; + + try + { + var result = MiiSerializer.Deserialize(miiData); + return result.IsSuccess ? result.Value : null; + } + catch + { + return null; + } + } + + private void SetLoadingState() + { + HasError = false; + HasNoData = false; + HasData = false; + ErrorMessage = string.Empty; + } + + private void SetErrorState(string message) + { + HasError = true; + HasNoData = false; + HasData = false; + ErrorMessage = string.IsNullOrWhiteSpace(message) ? "Failed to load leaderboard." : message; + } + + private void SetEmptyState() + { + HasError = false; + HasNoData = true; + HasData = false; + } + + private void SetDataState() + { + HasError = false; + HasNoData = false; + HasData = true; + } + + private void ClearLeaderboardData() + { + PodiumFirst = null; + PodiumSecond = null; + PodiumThird = null; + RemainingPlayers.Clear(); + _loadedPlayerCount = 0; + + OnPropertyChanged(nameof(TotalPlayerCountText)); + OnPropertyChanged(nameof(RemainingCountText)); + } + + private void CancelCurrentLoad() + { + if (_loadCts == null) + return; + + _loadCts.Cancel(); + _loadCts.Dispose(); + _loadCts = null; + } + + private void CopyFriendCode_OnClick(object sender, RoutedEventArgs e) + { + var player = GetContextPlayer(sender); + if (player == null || string.IsNullOrWhiteSpace(player.FriendCode)) + return; + + TopLevel.GetTopLevel(this)?.Clipboard?.SetTextAsync(player.FriendCode); + ViewUtils.ShowSnackbar(Phrases.SnackbarSuccess_CopiedFC); + } + + private void OpenCarousel_OnClick(object sender, RoutedEventArgs e) + { + var player = GetContextPlayer(sender); + if (player?.FirstMii == null) + return; + + new MiiCarouselWindow().SetMii(player.FirstMii).Show(); + } + + private void ViewProfile_OnClick(object sender, RoutedEventArgs e) + { + var player = GetContextPlayer(sender); + if (player == null || string.IsNullOrWhiteSpace(player.FriendCode)) + return; + + new PlayerProfileWindow(player.FriendCode).Show(); + } + + private async void AddFriend_OnClick(object sender, RoutedEventArgs e) + { + var player = GetContextPlayer(sender); + if (player == null) + return; + + if (player.FirstMii == null) + { + ViewUtils.ShowSnackbar("This player has no valid Mii data.", ViewUtils.SnackbarType.Warning); + return; + } + + var focusedUserIndex = (int)SettingsManager.FOCUSSED_USER.Get(); + if (focusedUserIndex is < 0 or > 3) + { + ViewUtils.ShowSnackbar("Invalid license selected.", ViewUtils.SnackbarType.Warning); + return; + } + + var activeUserPid = FriendCodeGenerator.FriendCodeToProfileId(GameDataService.ActiveUser.FriendCode); + if (activeUserPid == 0) + { + ViewUtils.ShowSnackbar("Select a valid license before adding friends.", ViewUtils.SnackbarType.Warning); + return; + } + + if (GameDataService.ActiveCurrentFriends.Count >= 30) + { + ViewUtils.ShowSnackbar("Your friend list is full.", ViewUtils.SnackbarType.Warning); + return; + } + + var normalizedFriendCodeResult = NormalizeFriendCode(player.FriendCode); + if (normalizedFriendCodeResult.IsFailure) + { + ViewUtils.ShowSnackbar(normalizedFriendCodeResult.Error.Message, ViewUtils.SnackbarType.Warning); + return; + } + + var normalizedFriendCode = normalizedFriendCodeResult.Value; + var friendProfileId = FriendCodeGenerator.FriendCodeToProfileId(normalizedFriendCode); + if (activeUserPid == friendProfileId) + { + ViewUtils.ShowSnackbar("You cannot add your own friend code.", ViewUtils.SnackbarType.Warning); + return; + } + + var duplicateFriend = GameDataService.ActiveCurrentFriends.Any(friend => + { + var existingPid = FriendCodeGenerator.FriendCodeToProfileId(friend.FriendCode); + return existingPid != 0 && existingPid == friendProfileId; + }); + + if (duplicateFriend) + { + ViewUtils.ShowSnackbar("This friend is already in your list.", ViewUtils.SnackbarType.Warning); + return; + } + + var profile = new PlayerProfileResponse + { + Name = player.Name, + FriendCode = normalizedFriendCode, + Vr = int.TryParse(player.VrText.Replace(",", string.Empty), out var vr) ? vr : 0, + }; + + var shouldAdd = await new AddFriendConfirmationWindow(profile, player.FirstMii).AwaitAnswer(); + if (!shouldAdd) + return; + + var addResult = GameDataService.AddFriend(focusedUserIndex, normalizedFriendCode, player.FirstMii, (uint)Math.Max(profile.Vr, 0)); + + if (addResult.IsFailure) + { + ViewUtils.ShowSnackbar(addResult.Error.Message, ViewUtils.SnackbarType.Warning); + return; + } + + ViewUtils.GetLayout().UpdateFriendCount(); + ViewUtils.ShowSnackbar($"Added {player.Name} to your friend list."); + } + + private static LeaderboardPlayerItem? GetContextPlayer(object sender) + { + if (sender is not Control control) + return null; + return control.DataContext as LeaderboardPlayerItem; + } + + private static OperationResult NormalizeFriendCode(string friendCode) + { + if (string.IsNullOrWhiteSpace(friendCode)) + return Fail("Friend code cannot be empty."); + + var digits = new string(friendCode.Where(char.IsDigit).ToArray()); + if (digits.Length != 12 || !ulong.TryParse(digits, out _)) + return Fail("Friend code must be exactly 12 digits."); + + var formatted = $"{digits[..4]}-{digits.Substring(4, 4)}-{digits.Substring(8, 4)}"; + var profileId = FriendCodeGenerator.FriendCodeToProfileId(formatted); + if (profileId == 0) + return Fail("Invalid friend code."); + + return formatted; + } + + public new event PropertyChangedEventHandler? PropertyChanged; + + private void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new(propertyName)); + } +} diff --git a/WheelWizard/Views/Pages/LeaderboardPlayerItem.cs b/WheelWizard/Views/Pages/LeaderboardPlayerItem.cs new file mode 100644 index 00000000..ddd4dbd3 --- /dev/null +++ b/WheelWizard/Views/Pages/LeaderboardPlayerItem.cs @@ -0,0 +1,26 @@ +using WheelWizard.WheelWizardData.Domain; +using WheelWizard.WiiManagement.MiiManagement.Domain.Mii; + +namespace WheelWizard.Views.Pages; + +public sealed class LeaderboardPlayerItem +{ + public required int Rank { get; init; } + public required string PlacementLabel { get; init; } + public required string Name { get; init; } + public required string FriendCode { get; init; } + public required string VrText { get; init; } + public Mii? Mii { get; init; } + public BadgeVariant PrimaryBadge { get; init; } + public bool HasBadge { get; init; } + public bool IsSuspicious { get; init; } + public bool IsEvenRow { get; init; } + + // Keep parity with RoomDetailsPage player template bindings. + public string VrDisplay => VrText; + public Mii? FirstMii => Mii; + public bool HasBadges => HasBadge; + public bool IsTopLeaderboardPlayer => true; + public string TopLabel => $"#{Rank}"; + public bool IsOpenHost => false; +} From c3f0a41c1caa9abb410626b7a86d5dc64597ec7c Mon Sep 17 00:00:00 2001 From: Patchzy <64382339+patchzyy@users.noreply.github.com> Date: Wed, 18 Feb 2026 01:36:50 +0100 Subject: [PATCH 2/8] Add leaderboard caching and loading UI --- .../RrRooms/RrLeaderboardSingletonService.cs | 83 ++++++++++- .../WhWzLibrary/LeaderboardPodiumCard.axaml | 2 +- .../WhWzLibrary/PlayerListItem.axaml | 20 ++- WheelWizard/Views/Pages/LeaderboardPage.axaml | 27 +++- .../Views/Pages/LeaderboardPage.axaml.cs | 134 +++++++++++------- 5 files changed, 205 insertions(+), 61 deletions(-) diff --git a/WheelWizard/Features/RrRooms/RrLeaderboardSingletonService.cs b/WheelWizard/Features/RrRooms/RrLeaderboardSingletonService.cs index bedddc6c..54dac2c9 100644 --- a/WheelWizard/Features/RrRooms/RrLeaderboardSingletonService.cs +++ b/WheelWizard/Features/RrRooms/RrLeaderboardSingletonService.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging; using WheelWizard.Shared.Services; namespace WheelWizard.RrRooms; @@ -7,11 +8,89 @@ public interface IRrLeaderboardSingletonService Task>> GetTopPlayersAsync(int limit = 50); } -public class RrLeaderboardSingletonService(IApiCaller apiCaller) : IRrLeaderboardSingletonService +public class RrLeaderboardSingletonService(IApiCaller apiCaller, ILogger logger) + : IRrLeaderboardSingletonService { + private static readonly TimeSpan CacheLifetime = TimeSpan.FromSeconds(90); + private readonly SemaphoreSlim _refreshGate = new(1, 1); + private readonly object _cacheLock = new(); + + private DateTimeOffset _cacheFetchedAt = DateTimeOffset.MinValue; + private List _cachedEntries = []; + public async Task>> GetTopPlayersAsync(int limit = 50) { var boundedLimit = Math.Clamp(limit, 1, 200); - return await apiCaller.CallApiAsync(api => api.GetTopLeaderboardAsync(boundedLimit)); + + if (TryGetFreshCache(boundedLimit, out var freshCache)) + return freshCache; + + await _refreshGate.WaitAsync(); + + try + { + if (TryGetFreshCache(boundedLimit, out freshCache)) + return freshCache; + + var fetchResult = await apiCaller.CallApiAsync(api => api.GetTopLeaderboardAsync(boundedLimit)); + if (fetchResult.IsFailure) + { + if (TryGetAnyCache(boundedLimit, out var staleCache)) + { + logger.LogWarning("RWFC leaderboard fetch failed; returning stale cached leaderboard for top {Limit}.", boundedLimit); + return staleCache; + } + + return fetchResult; + } + + lock (_cacheLock) + { + _cachedEntries = fetchResult.Value.ToList(); + _cacheFetchedAt = DateTimeOffset.UtcNow; + } + + return TrimToLimit(fetchResult.Value, boundedLimit); + } + finally + { + _refreshGate.Release(); + } + } + + private bool TryGetFreshCache(int limit, out OperationResult> result) + { + lock (_cacheLock) + { + var hasFreshCache = DateTimeOffset.UtcNow - _cacheFetchedAt <= CacheLifetime; + if (!hasFreshCache || _cachedEntries.Count < limit) + { + result = default!; + return false; + } + + result = TrimToLimit(_cachedEntries, limit); + return true; + } + } + + private bool TryGetAnyCache(int limit, out OperationResult> result) + { + lock (_cacheLock) + { + if (_cachedEntries.Count < limit) + { + result = default!; + return false; + } + + result = TrimToLimit(_cachedEntries, limit); + return true; + } + } + + private static OperationResult> TrimToLimit(IEnumerable entries, int limit) + { + return entries.Take(limit).ToList(); } } diff --git a/WheelWizard/Views/Components/WhWzLibrary/LeaderboardPodiumCard.axaml b/WheelWizard/Views/Components/WhWzLibrary/LeaderboardPodiumCard.axaml index 64104b03..41938974 100644 --- a/WheelWizard/Views/Components/WhWzLibrary/LeaderboardPodiumCard.axaml +++ b/WheelWizard/Views/Components/WhWzLibrary/LeaderboardPodiumCard.axaml @@ -66,7 +66,7 @@ BorderThickness="1" CornerRadius="20" Padding="10,14,10,10" - Margin="4,5,4,5" + Margin="4,8,4,5" ClipToBounds="True" BoxShadow="0 18 30 0 #22000000"> - - + - \ No newline at end of file + + + + + diff --git a/WheelWizard/Views/Pages/LeaderboardPage.axaml b/WheelWizard/Views/Pages/LeaderboardPage.axaml index d11de3cb..202b4b81 100644 --- a/WheelWizard/Views/Pages/LeaderboardPage.axaml +++ b/WheelWizard/Views/Pages/LeaderboardPage.axaml @@ -55,6 +55,14 @@ + + @@ -90,6 +98,21 @@ + + + + + + IsTopPlayer="True" + TopLabel="{Binding TopLabel}"> _isLoading; + private set + { + if (_isLoading == value) + return; + _isLoading = value; + OnPropertyChanged(nameof(IsLoading)); + } + } + public bool HasNoData { get => _hasNoData; @@ -98,46 +124,25 @@ private set public LeaderboardPlayerItem? PodiumFirst { - get => _podiumFirst; - private set - { - if (_podiumFirst == value) - return; - _podiumFirst = value; - OnPropertyChanged(nameof(PodiumFirst)); - OnPropertyChanged(nameof(HasPodiumFirst)); - } + get => _podiumFirst ?? EmptyPodiumPlayer; + private set => SetPodiumPlayer(ref _podiumFirst, value, nameof(PodiumFirst), nameof(HasPodiumFirst)); } public LeaderboardPlayerItem? PodiumSecond { - get => _podiumSecond; - private set - { - if (_podiumSecond == value) - return; - _podiumSecond = value; - OnPropertyChanged(nameof(PodiumSecond)); - OnPropertyChanged(nameof(HasPodiumSecond)); - } + get => _podiumSecond ?? EmptyPodiumPlayer; + private set => SetPodiumPlayer(ref _podiumSecond, value, nameof(PodiumSecond), nameof(HasPodiumSecond)); } public LeaderboardPlayerItem? PodiumThird { - get => _podiumThird; - private set - { - if (_podiumThird == value) - return; - _podiumThird = value; - OnPropertyChanged(nameof(PodiumThird)); - OnPropertyChanged(nameof(HasPodiumThird)); - } + get => _podiumThird ?? EmptyPodiumPlayer; + private set => SetPodiumPlayer(ref _podiumThird, value, nameof(PodiumThird), nameof(HasPodiumThird)); } - public bool HasPodiumFirst => PodiumFirst != null; - public bool HasPodiumSecond => PodiumSecond != null; - public bool HasPodiumThird => PodiumThird != null; + public bool HasPodiumFirst => _podiumFirst != null; + public bool HasPodiumSecond => _podiumSecond != null; + public bool HasPodiumThird => _podiumThird != null; public LeaderboardPage() { @@ -181,6 +186,7 @@ private async Task ReloadLeaderboardAsync() SetLoadingState(); ClearLeaderboardData(); + await Task.Yield(); var leaderboardResult = await LeaderboardService.GetTopPlayersAsync(50); if (cancellationToken.IsCancellationRequested) @@ -204,7 +210,21 @@ private async Task ReloadLeaderboardAsync() return; } - var mappedPlayers = orderedEntries.Select((entry, index) => CreateLeaderboardPlayer(entry.Entry, entry.Rank, index)).ToList(); + List mappedPlayers; + try + { + mappedPlayers = await Task.Run( + () => orderedEntries.Select((entry, index) => CreateLeaderboardPlayer(entry.Entry, entry.Rank, index)).ToList(), + cancellationToken + ); + } + catch (OperationCanceledException) + { + return; + } + + if (cancellationToken.IsCancellationRequested) + return; _loadedPlayerCount = mappedPlayers.Count; OnPropertyChanged(nameof(TotalPlayerCountText)); @@ -213,21 +233,12 @@ private async Task ReloadLeaderboardAsync() PodiumSecond = mappedPlayers.ElementAtOrDefault(1); PodiumThird = mappedPlayers.ElementAtOrDefault(2); + if (cancellationToken.IsCancellationRequested) + return; + foreach (var player in mappedPlayers.Skip(3)) { - if (cancellationToken.IsCancellationRequested) - return; - RemainingPlayers.Add(player); - - try - { - await Task.Delay(12, cancellationToken); - } - catch (OperationCanceledException) - { - return; - } } SetDataState(); @@ -279,19 +290,37 @@ private static string GetPlacementLabel(int rank) => if (string.IsNullOrWhiteSpace(miiData)) return null; - try - { - var result = MiiSerializer.Deserialize(miiData); - return result.IsSuccess ? result.Value : null; - } - catch - { + // Mii block payload should be ~100 base64 chars (74 bytes decoded). + // Guarding the size avoids expensive decode failures for large non-Mii payloads. + if (miiData.Length is < 90 or > 120) return null; - } + + var buffer = new byte[MiiSerializer.MiiBlockSize]; + if (!Convert.TryFromBase64String(miiData, buffer, out var bytesWritten) || bytesWritten != MiiSerializer.MiiBlockSize) + return null; + + var result = MiiSerializer.Deserialize(buffer); + return result.IsSuccess ? result.Value : null; + } + + private void SetPodiumPlayer( + ref LeaderboardPlayerItem? field, + LeaderboardPlayerItem? value, + string propertyName, + string hasPropertyName + ) + { + if (field == value) + return; + + field = value; + OnPropertyChanged(propertyName); + OnPropertyChanged(hasPropertyName); } private void SetLoadingState() { + IsLoading = true; HasError = false; HasNoData = false; HasData = false; @@ -300,6 +329,7 @@ private void SetLoadingState() private void SetErrorState(string message) { + IsLoading = false; HasError = true; HasNoData = false; HasData = false; @@ -308,6 +338,7 @@ private void SetErrorState(string message) private void SetEmptyState() { + IsLoading = false; HasError = false; HasNoData = true; HasData = false; @@ -315,6 +346,7 @@ private void SetEmptyState() private void SetDataState() { + IsLoading = false; HasError = false; HasNoData = false; HasData = true; From 36520ff5e99000a6c975e4700af68d1e52fa845c Mon Sep 17 00:00:00 2001 From: Patchzy <64382339+patchzyy@users.noreply.github.com> Date: Wed, 18 Feb 2026 01:39:41 +0100 Subject: [PATCH 3/8] Update LeaderboardPage.axaml --- WheelWizard/Views/Pages/LeaderboardPage.axaml | 48 +++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/WheelWizard/Views/Pages/LeaderboardPage.axaml b/WheelWizard/Views/Pages/LeaderboardPage.axaml index 202b4b81..f60403ab 100644 --- a/WheelWizard/Views/Pages/LeaderboardPage.axaml +++ b/WheelWizard/Views/Pages/LeaderboardPage.axaml @@ -201,7 +201,21 @@ Mii="{Binding PodiumSecond.Mii}" ShowBadge="{Binding PodiumSecond.HasBadge}" BadgeVariant="{Binding PodiumSecond.PrimaryBadge}" - IsSuspicious="{Binding PodiumSecond.IsSuspicious}" /> + IsSuspicious="{Binding PodiumSecond.IsSuspicious}"> + + + + + + + + + + + IsSuspicious="{Binding PodiumFirst.IsSuspicious}"> + + + + + + + + + + + IsSuspicious="{Binding PodiumThird.IsSuspicious}"> + + + + + + + + + + From 0dcc00961039c22331814dffaaa19089faa60c3c Mon Sep 17 00:00:00 2001 From: Patchzy <64382339+patchzyy@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:08:01 +0100 Subject: [PATCH 4/8] Add friend/online flags and join-room UI --- WheelWizard/Models/RRInfo/RrPlayer.cs | 1 + WheelWizard/Services/LiveData/RRLiveRooms.cs | 24 +++++- .../WhWzLibrary/PlayerListItem.axaml | 28 ++++++- .../WhWzLibrary/PlayerListItem.axaml.cs | 73 +++++++++++++++---- WheelWizard/Views/Pages/LeaderboardPage.axaml | 3 + .../Views/Pages/LeaderboardPage.axaml.cs | 46 +++++++++++- .../Views/Pages/LeaderboardPlayerItem.cs | 2 + WheelWizard/Views/Pages/RoomsPage.axaml | 2 +- 8 files changed, 156 insertions(+), 23 deletions(-) diff --git a/WheelWizard/Models/RRInfo/RrPlayer.cs b/WheelWizard/Models/RRInfo/RrPlayer.cs index cadd5c18..7a12bca0 100644 --- a/WheelWizard/Models/RRInfo/RrPlayer.cs +++ b/WheelWizard/Models/RRInfo/RrPlayer.cs @@ -14,6 +14,7 @@ public class RrPlayer : IEquatable public bool IsOpenHost { get; set; } public bool IsSuspended { get; set; } + public bool IsFriend { get; set; } public int? LeaderboardRank { get; set; } public bool IsTopLeaderboardPlayer => LeaderboardRank.HasValue; diff --git a/WheelWizard/Services/LiveData/RRLiveRooms.cs b/WheelWizard/Services/LiveData/RRLiveRooms.cs index 99aef535..e4594769 100644 --- a/WheelWizard/Services/LiveData/RRLiveRooms.cs +++ b/WheelWizard/Services/LiveData/RRLiveRooms.cs @@ -1,9 +1,11 @@ using WheelWizard.Models.RRInfo; using WheelWizard.RrRooms; +using WheelWizard.Utilities.Generators; using WheelWizard.Utilities.RepeatedTasks; using WheelWizard.Views; using WheelWizard.WheelWizardData; using WheelWizard.WiiManagement; +using WheelWizard.WiiManagement.GameLicense; using WheelWizard.WiiManagement.MiiManagement; using WheelWizard.WiiManagement.MiiManagement.Domain.Mii; @@ -26,6 +28,7 @@ protected override async Task ExecuteTaskAsync() var whWzService = App.Services.GetRequiredService(); var roomsService = App.Services.GetRequiredService(); var leaderboardService = App.Services.GetRequiredService(); + var gameLicenseService = App.Services.GetRequiredService(); var roomsTask = roomsService.GetRoomsAsync(); var leaderboardTask = leaderboardService.GetTopPlayersAsync(50); @@ -59,7 +62,14 @@ protected override async Task ExecuteTaskAsync() var raw = roomsResult.Value; var splitRaw = SplitMergedRooms(raw); - var rrRooms = splitRaw.Select(room => MapRoom(room, whWzService, leaderboardByPid, leaderboardByFriendCode)).ToList(); + var friendProfileIds = gameLicenseService + .ActiveCurrentFriends.Select(friend => FriendCodeGenerator.FriendCodeToProfileId(friend.FriendCode)) + .Where(profileId => profileId != 0) + .ToHashSet(); + + var rrRooms = splitRaw + .Select(room => MapRoom(room, whWzService, leaderboardByPid, leaderboardByFriendCode, friendProfileIds)) + .ToList(); CurrentRooms = rrRooms; } @@ -68,7 +78,8 @@ private static RrRoom MapRoom( RwfcRoomStatusRoom room, IWhWzDataSingletonService whWzService, IReadOnlyDictionary leaderboardByPid, - IReadOnlyDictionary leaderboardByFriendCode + IReadOnlyDictionary leaderboardByFriendCode, + IReadOnlySet friendProfileIds ) { return new() @@ -78,7 +89,9 @@ IReadOnlyDictionary leaderboardByFriendCode Type = room.Type, Suspend = room.Suspend, Rk = room.Rk, - Players = room.Players.Select(p => MapPlayer(p, whWzService, leaderboardByPid, leaderboardByFriendCode)).ToList(), + Players = room + .Players.Select(p => MapPlayer(p, whWzService, leaderboardByPid, leaderboardByFriendCode, friendProfileIds)) + .ToList(), }; } @@ -86,7 +99,8 @@ private static RrPlayer MapPlayer( RwfcRoomStatusPlayer p, IWhWzDataSingletonService whWzService, IReadOnlyDictionary leaderboardByPid, - IReadOnlyDictionary leaderboardByFriendCode + IReadOnlyDictionary leaderboardByFriendCode, + IReadOnlySet friendProfileIds ) { Mii? mii = null; @@ -106,6 +120,7 @@ IReadOnlyDictionary leaderboardByFriendCode } var friendCode = p.FriendCode ?? string.Empty; + var profileId = FriendCodeGenerator.FriendCodeToProfileId(friendCode); var leaderboardEntry = GetLeaderboardEntry(p, friendCode, leaderboardByPid, leaderboardByFriendCode); @@ -122,6 +137,7 @@ IReadOnlyDictionary leaderboardByFriendCode Mii = mii, BadgeVariants = whWzService.GetBadges(friendCode), LeaderboardRank = leaderboardEntry?.Rank ?? leaderboardEntry?.ActiveRank, + IsFriend = profileId != 0 && friendProfileIds.Contains(profileId), }; } diff --git a/WheelWizard/Views/Components/WhWzLibrary/PlayerListItem.axaml b/WheelWizard/Views/Components/WhWzLibrary/PlayerListItem.axaml index 9dbe492d..c463717a 100644 --- a/WheelWizard/Views/Components/WhWzLibrary/PlayerListItem.axaml +++ b/WheelWizard/Views/Components/WhWzLibrary/PlayerListItem.axaml @@ -73,7 +73,7 @@ Margin="10,0,0,0" /> - + + + - + + diff --git a/WheelWizard/Views/Components/WhWzLibrary/PlayerListItem.axaml.cs b/WheelWizard/Views/Components/WhWzLibrary/PlayerListItem.axaml.cs index 969ac2b6..8b26cc67 100644 --- a/WheelWizard/Views/Components/WhWzLibrary/PlayerListItem.axaml.cs +++ b/WheelWizard/Views/Components/WhWzLibrary/PlayerListItem.axaml.cs @@ -1,6 +1,7 @@ -using Avalonia; +using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; using WheelWizard.WheelWizardData; using WheelWizard.WiiManagement.MiiManagement.Domain.Mii; @@ -8,6 +9,13 @@ namespace WheelWizard.Views.Components; public class PlayerListItem : TemplatedControl { + private Button? _joinRoomButton; + + public static readonly StyledProperty IsOnlineProperty = AvaloniaProperty.Register(nameof(IsOnline)); + public static readonly StyledProperty ShowJoinRoomButtonProperty = AvaloniaProperty.Register( + nameof(ShowJoinRoomButton) + ); + public static readonly StyledProperty HasBadgesProperty = AvaloniaProperty.Register(nameof(HasBadges)); public static readonly StyledProperty IsOpenHostProperty = AvaloniaProperty.Register(nameof(IsOpenHost)); @@ -25,6 +33,18 @@ public bool HasBadges set => SetValue(HasBadgesProperty, value); } + public bool IsOnline + { + get => GetValue(IsOnlineProperty); + set => SetValue(IsOnlineProperty, value); + } + + public bool ShowJoinRoomButton + { + get => GetValue(ShowJoinRoomButtonProperty); + set => SetValue(ShowJoinRoomButtonProperty, value); + } + public bool IsOpenHost { get => GetValue(IsOpenHostProperty); @@ -85,23 +105,50 @@ public string UserName set => SetValue(UserNameProperty, value); } + public static readonly StyledProperty?> JoinRoomActionProperty = AvaloniaProperty.Register< + PlayerListItem, + Action? + >(nameof(JoinRoomAction)); + + public Action? JoinRoomAction + { + get => GetValue(JoinRoomActionProperty); + set => SetValue(JoinRoomActionProperty, value); + } + + private void JoinRoom_OnClick(object? sender, RoutedEventArgs e) + { + if (string.IsNullOrWhiteSpace(FriendCode)) + return; + + JoinRoomAction?.Invoke(FriendCode); + } + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { base.OnApplyTemplate(e); - var container = e.NameScope.Find("PART_BadgeContainer"); - if (container == null) - return; - container.Children.Clear(); - var badges = App - .Services.GetRequiredService() - .GetBadges(FriendCode) - .Select(variant => new Badge { Variant = variant }); - foreach (var badge in badges) + var container = e.NameScope.Find("PART_BadgeContainer"); + if (container != null) { - badge.Height = 30; - badge.Width = 30; - container.Children.Add(badge); + container.Children.Clear(); + var badges = App + .Services.GetRequiredService() + .GetBadges(FriendCode) + .Select(variant => new Badge { Variant = variant }); + foreach (var badge in badges) + { + badge.Height = 30; + badge.Width = 30; + container.Children.Add(badge); + } } + + if (_joinRoomButton != null) + _joinRoomButton.Click -= JoinRoom_OnClick; + + _joinRoomButton = e.NameScope.Find