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/Features/RrRooms/RrLeaderboardSingletonService.cs b/WheelWizard/Features/RrRooms/RrLeaderboardSingletonService.cs index bedddc6c..72576dce 100644 --- a/WheelWizard/Features/RrRooms/RrLeaderboardSingletonService.cs +++ b/WheelWizard/Features/RrRooms/RrLeaderboardSingletonService.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; using WheelWizard.Shared.Services; namespace WheelWizard.RrRooms; @@ -7,11 +9,59 @@ public interface IRrLeaderboardSingletonService Task>> GetTopPlayersAsync(int limit = 50); } -public class RrLeaderboardSingletonService(IApiCaller apiCaller) : IRrLeaderboardSingletonService +public class RrLeaderboardSingletonService( + IApiCaller apiCaller, + IMemoryCache cache, + ILogger logger +) : IRrLeaderboardSingletonService { + private const string FreshCacheKey = "rrrooms:leaderboard:fresh"; + private const string StaleCacheKey = "rrrooms:leaderboard:stale"; + private static readonly TimeSpan CacheLifetime = TimeSpan.FromSeconds(90); + public async Task>> GetTopPlayersAsync(int limit = 50) { var boundedLimit = Math.Clamp(limit, 1, 200); - return await apiCaller.CallApiAsync(api => api.GetTopLeaderboardAsync(boundedLimit)); + + if (TryGetCached(FreshCacheKey, boundedLimit, out var freshCache)) + return freshCache; + + var fetchResult = await apiCaller.CallApiAsync(api => api.GetTopLeaderboardAsync(boundedLimit)); + if (fetchResult.IsFailure) + { + if (!TryGetCached(StaleCacheKey, boundedLimit, out var staleCache)) + return fetchResult; + + logger.LogWarning("RWFC leaderboard fetch failed; returning stale cached leaderboard for top {Limit}.", boundedLimit); + return staleCache; + } + + var fetchedEntries = fetchResult.Value.ToList(); + + cache.Set(FreshCacheKey, fetchedEntries, new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = CacheLifetime }); + cache.Set(StaleCacheKey, fetchedEntries); + + return TrimToLimit(fetchedEntries, boundedLimit); + } + + private bool TryGetCached(string cacheKey, int limit, out OperationResult> result) + { + if ( + !cache.TryGetValue(cacheKey, out List? cachedEntries) + || cachedEntries == null + || 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/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/Resources/Languages/Common.Designer.cs b/WheelWizard/Resources/Languages/Common.Designer.cs index 47190ee5..b11771e9 100644 --- a/WheelWizard/Resources/Languages/Common.Designer.cs +++ b/WheelWizard/Resources/Languages/Common.Designer.cs @@ -1165,6 +1165,15 @@ public static string PageTitle_Friends { return ResourceManager.GetString("PageTitle_Friends", resourceCulture); } } + + /// + /// Looks up a localized string similar to Leaderboard. + /// + public static string PageTitle_Leaderboard { + get { + return ResourceManager.GetString("PageTitle_Leaderboard", resourceCulture); + } + } /// /// Looks up a localized string similar to Home. diff --git a/WheelWizard/Resources/Languages/Common.resx b/WheelWizard/Resources/Languages/Common.resx index 32b1de77..4a0c130c 100644 --- a/WheelWizard/Resources/Languages/Common.resx +++ b/WheelWizard/Resources/Languages/Common.resx @@ -41,6 +41,9 @@ Friends + + Leaderboard + Online diff --git a/WheelWizard/Services/LiveData/RRLiveRooms.cs b/WheelWizard/Services/LiveData/RRLiveRooms.cs index b9a50c08..bc33368d 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; @@ -14,6 +16,7 @@ public class RRLiveRooms : RepeatedTaskManager private readonly IWhWzDataSingletonService _whWzService; private readonly IRrRoomsSingletonService _roomsService; private readonly IRrLeaderboardSingletonService _leaderboardService; + private readonly IGameLicenseSingletonService _gameLicenseService; public List CurrentRooms { get; private set; } = []; public int PlayerCount => CurrentRooms.Sum(room => room.PlayerCount); @@ -24,13 +27,15 @@ public class RRLiveRooms : RepeatedTaskManager public RRLiveRooms( IWhWzDataSingletonService whWzService, IRrRoomsSingletonService roomsService, - IRrLeaderboardSingletonService leaderboardService + IRrLeaderboardSingletonService leaderboardService, + IGameLicenseSingletonService gameLicenseService ) : base(40) { _whWzService = whWzService; _roomsService = roomsService; _leaderboardService = leaderboardService; + _gameLicenseService = gameLicenseService; } protected override async Task ExecuteTaskAsync() @@ -67,7 +72,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; } @@ -76,7 +88,8 @@ private static RrRoom MapRoom( RwfcRoomStatusRoom room, IWhWzDataSingletonService whWzService, IReadOnlyDictionary leaderboardByPid, - IReadOnlyDictionary leaderboardByFriendCode + IReadOnlyDictionary leaderboardByFriendCode, + IReadOnlySet friendProfileIds ) { return new() @@ -86,7 +99,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(), }; } @@ -94,7 +109,8 @@ private static RrPlayer MapPlayer( RwfcRoomStatusPlayer p, IWhWzDataSingletonService whWzService, IReadOnlyDictionary leaderboardByPid, - IReadOnlyDictionary leaderboardByFriendCode + IReadOnlyDictionary leaderboardByFriendCode, + IReadOnlySet friendProfileIds ) { Mii? mii = null; @@ -114,6 +130,7 @@ IReadOnlyDictionary leaderboardByFriendCode } var friendCode = p.FriendCode ?? string.Empty; + var profileId = FriendCodeGenerator.FriendCodeToProfileId(friendCode); var leaderboardEntry = GetLeaderboardEntry(p, friendCode, leaderboardByPid, leaderboardByFriendCode); @@ -130,6 +147,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/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..41938974 --- /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/Components/WhWzLibrary/PlayerListItem.axaml b/WheelWizard/Views/Components/WhWzLibrary/PlayerListItem.axaml index b0a3f24d..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" /> - + - + + - + - - \ No newline at end of file + + + + + + + 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