From b944efff04cb7773e7e019792c91f6a66f960f4f Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:44:15 +0100 Subject: [PATCH] fix friend vr and br --- .../Features/RrRooms/RrLeaderboardTests.cs | 65 +++++++++++++++++++ .../WiiManagement/FriendRatingTests.cs | 34 ++++++++++ .../RrRooms/RrLeaderboardSingletonService.cs | 16 +++++ .../Views/FriendsPage.axaml.cs | 47 ++++++++++++++ .../GameLicense/Domain/PlayerProfileBase.cs | 15 ++++- .../GameLicense/GameLicenseService.cs | 23 +++++-- 6 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 WheelWizard.Test/Features/RrRooms/RrLeaderboardTests.cs create mode 100644 WheelWizard.Test/Features/WiiManagement/FriendRatingTests.cs diff --git a/WheelWizard.Test/Features/RrRooms/RrLeaderboardTests.cs b/WheelWizard.Test/Features/RrRooms/RrLeaderboardTests.cs new file mode 100644 index 000000000..be31e736f --- /dev/null +++ b/WheelWizard.Test/Features/RrRooms/RrLeaderboardTests.cs @@ -0,0 +1,65 @@ +using System.Linq.Expressions; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging.Abstractions; +using WheelWizard.Models; +using WheelWizard.RrRooms; +using WheelWizard.Shared; +using WheelWizard.Shared.Services; + +namespace WheelWizard.Test.Features.RrRooms; + +public class RrLeaderboardTests +{ + private const string FriendCode = "3137-3137-3137"; + + private readonly IApiCaller _apiCaller = Substitute.For>(); + private readonly RrLeaderboardSingletonService _service; + + public RrLeaderboardTests() + { + _service = new(_apiCaller, new MemoryCache(new MemoryCacheOptions()), NullLogger.Instance); + } + + private Task> ReceivedPlayerCalls(int count) => + _apiCaller.Received(count).CallApiAsync(Arg.Any>>>()); + + [Fact] + public async Task GetPlayerAsync_FetchesPlayerOnce() + { + _apiCaller + .CallApiAsync(Arg.Any>>>()) + .Returns(Ok(new PlayerProfileResponse { FriendCode = FriendCode, Vr = 29_367 })); + + var first = await _service.GetPlayerAsync(FriendCode); + var second = await _service.GetPlayerAsync(FriendCode); + + Assert.Equal(29_367, first.Value.Vr); + Assert.Equal(29_367, second.Value.Vr); + await ReceivedPlayerCalls(1); + } + + [Fact] + public async Task GetPlayerAsync_DoesNotRetryFailure() + { + _apiCaller.CallApiAsync(Arg.Any>>>()).Returns(Fail("Not found")); + + Assert.True((await _service.GetPlayerAsync(FriendCode)).IsFailure); + Assert.True((await _service.GetPlayerAsync(FriendCode)).IsFailure); + await ReceivedPlayerCalls(1); + } + + [Fact] + public async Task GetPlayerAsync_SharesRequestStillLoading() + { + var response = new TaskCompletionSource>(); + _apiCaller.CallApiAsync(Arg.Any>>>()).Returns(response.Task); + + var first = _service.GetPlayerAsync(FriendCode); + var second = _service.GetPlayerAsync(FriendCode); + response.SetResult(Ok(new PlayerProfileResponse { Vr = 29_367 })); + + Assert.Equal(29_367, (await first).Value.Vr); + Assert.Equal(29_367, (await second).Value.Vr); + await ReceivedPlayerCalls(1); + } +} diff --git a/WheelWizard.Test/Features/WiiManagement/FriendRatingTests.cs b/WheelWizard.Test/Features/WiiManagement/FriendRatingTests.cs new file mode 100644 index 000000000..0ac2e55d2 --- /dev/null +++ b/WheelWizard.Test/Features/WiiManagement/FriendRatingTests.cs @@ -0,0 +1,34 @@ +using WheelWizard.WiiManagement.GameLicense; + +namespace WheelWizard.Test.Features.WiiManagement; + +public class FriendRatingTests +{ + [Theory] + [InlineData(293u, 29_300u)] // Written by Retro Rewind for 29,367 VR + [InlineData(1u, 100u)] + [InlineData(10_000u, 1_000_000u)] // Highest rating + [InlineData(5000u, 5000u)] // Default written by older WheelWizard versions + [InlineData(29_367u, 29_367u)] // Full points written by older WheelWizard versions + public void FriendRatingFromRksys_ReturnsRatingPoints(uint stored, uint points) + { + Assert.Equal(points, GameLicenseSingletonService.FriendRatingFromRksys(stored)); + } + + [Theory] + [InlineData(29_367u, 293)] + [InlineData(5000u, 50)] + [InlineData(2_000_000u, 10_000)] // Above the highest rating + public void FriendRatingToRksys_StoresWholeHundreds(uint points, ushort stored) + { + Assert.Equal(stored, GameLicenseSingletonService.FriendRatingToRksys(points)); + } + + [Fact] + public void FriendRating_RoundTripsDefaultRating() + { + var stored = GameLicenseSingletonService.FriendRatingToRksys(5000); + + Assert.Equal(5000u, GameLicenseSingletonService.FriendRatingFromRksys(stored)); + } +} diff --git a/WheelWizard/Features/RrRooms/RrLeaderboardSingletonService.cs b/WheelWizard/Features/RrRooms/RrLeaderboardSingletonService.cs index 72576dce2..fad4deff8 100644 --- a/WheelWizard/Features/RrRooms/RrLeaderboardSingletonService.cs +++ b/WheelWizard/Features/RrRooms/RrLeaderboardSingletonService.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; +using WheelWizard.Models; using WheelWizard.Shared.Services; namespace WheelWizard.RrRooms; @@ -7,6 +8,9 @@ namespace WheelWizard.RrRooms; public interface IRrLeaderboardSingletonService { Task>> GetTopPlayersAsync(int limit = 50); + + /// A player's leaderboard data, cached for a few minutes (failures too, so they are not retried on every call). + Task> GetPlayerAsync(string friendCode); } public class RrLeaderboardSingletonService( @@ -18,6 +22,7 @@ ILogger logger private const string FreshCacheKey = "rrrooms:leaderboard:fresh"; private const string StaleCacheKey = "rrrooms:leaderboard:stale"; private static readonly TimeSpan CacheLifetime = TimeSpan.FromSeconds(90); + private static readonly TimeSpan PlayerCacheLifetime = TimeSpan.FromMinutes(5); public async Task>> GetTopPlayersAsync(int limit = 50) { @@ -44,6 +49,17 @@ public async Task>> GetTopPlayersAsyn return TrimToLimit(fetchedEntries, boundedLimit); } + // The request itself is cached, so asking for a player that is still loading does not fetch it again. + public Task> GetPlayerAsync(string friendCode) => + cache.GetOrCreate( + $"rrrooms:player:{friendCode}", + entry => + { + entry.AbsoluteExpirationRelativeToNow = PlayerCacheLifetime; + return apiCaller.CallApiAsync(api => api.GetPlayerProfileAsync(friendCode)); + } + )!; + private bool TryGetCached(string cacheKey, int limit, out OperationResult> result) { if ( diff --git a/WheelWizard/Features/WheelWizardData/Views/FriendsPage.axaml.cs b/WheelWizard/Features/WheelWizardData/Views/FriendsPage.axaml.cs index 815ee0115..f5e13319c 100644 --- a/WheelWizard/Features/WheelWizardData/Views/FriendsPage.axaml.cs +++ b/WheelWizard/Features/WheelWizardData/Views/FriendsPage.axaml.cs @@ -36,6 +36,8 @@ public partial class FriendsPage : UserControl, INotifyPropertyChanged, IPolling private LiveRoomsService LiveRooms { get; } + private IRrLeaderboardSingletonService Leaderboard { get; } + private IGameLicenseSingletonService GameLicenseService { get; } private IMiiDbService MiiDbService { get; } @@ -98,6 +100,7 @@ public FriendsPage( IModalFactory popups, INavigationService navigation, LiveRoomsService liveRooms, + IRrLeaderboardSingletonService leaderboard, IGameLicenseSingletonService gameLicenseService, IMiiDbService miiDbService, ISettingsManager settingsService @@ -106,6 +109,7 @@ ISettingsManager settingsService Popups = popups; Navigation = navigation; LiveRooms = liveRooms; + Leaderboard = leaderboard; GameLicenseService = gameLicenseService; MiiDbService = miiDbService; SettingsService = settingsService; @@ -133,6 +137,49 @@ public void OnUpdate(ObservablePollingService sender) private bool _addingCards; private void UpdateFriendList() + { + var ratings = LoadRatingsAsync(GameLicenseService.ActiveCurrentFriends); + ShowFriendList(); + // Sort again once the missing ratings are loaded + if (!ratings.IsCompleted) + ratings.ContinueWith(_ => Dispatcher.UIThread.Post(ShowFriendList)); + } + + /// + /// rksys.dat only has a friend's VR to the hundred, as of the last time the game saw them. Online friends get their + /// exact VR from their live room, the others from the RWFC player data. + /// + private Task LoadRatingsAsync(List friends) + { + var lookups = new List(); + foreach (var friend in friends) + { + if (LiveVr(friend) is { } liveVr) + friend.Vr = liveVr; + else + lookups.Add(LoadVrAsync(friend)); + } + return Task.WhenAll(lookups); + } + + private async Task LoadVrAsync(FriendProfile friend) + { + var player = await Leaderboard.GetPlayerAsync(friend.FriendCode); + // The friend may have come online while loading, their live VR is newer then + if (LiveVr(friend) is { } liveVr) + friend.Vr = liveVr; + else if (player.IsSuccess && player.Value.Vr > 0) + friend.Vr = (uint)player.Value.Vr; + } + + private uint? LiveVr(FriendProfile friend) => + (uint?) + LiveRooms + .CurrentRooms.SelectMany(room => room.Players) + .FirstOrDefault(player => player.FriendCode == friend.FriendCode && player.Vr > 0) + ?.Vr; + + private void ShowFriendList() { _friends = GetSortedPlayerList(); ApplyFriendList(Math.Max(FriendList.Count, CardsAtOnce)); diff --git a/WheelWizard/Features/WiiManagement/GameLicense/Domain/PlayerProfileBase.cs b/WheelWizard/Features/WiiManagement/GameLicense/Domain/PlayerProfileBase.cs index b445bb120..d33de7a60 100644 --- a/WheelWizard/Features/WiiManagement/GameLicense/Domain/PlayerProfileBase.cs +++ b/WheelWizard/Features/WiiManagement/GameLicense/Domain/PlayerProfileBase.cs @@ -8,9 +8,22 @@ namespace WheelWizard.WiiManagement.GameLicense.Domain; public abstract class PlayerProfileBase : INotifyPropertyChanged { public required string FriendCode { get; init; } - public required uint Vr { get; init; } public required uint Br { get; init; } public required uint RegionId { get; init; } + + private uint _vr; + public required uint Vr + { + get => _vr; + set + { + if (value == _vr) + return; + _vr = value; + OnPropertyChanged(nameof(Vr)); + } + } + public required Mii? Mii { get; set; } public string RegionName => GameLicenseDisplay.GetRegionName(RegionId); diff --git a/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs b/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs index 06b95d005..afade5263 100644 --- a/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs +++ b/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs @@ -152,6 +152,21 @@ ILogger logger private const byte DefaultFriendCountryCode = 0xFF; private const byte DefaultFriendRegionId = 0xFF; + // Retro Rewind stores a friend's VR/BR in rksys.dat as whole hundreds (29,367 VR is stored as 293). + private const uint FriendRatingScale = 100; + private const uint DefaultRating = 5000; + private const uint MaxRating = 1_000_000; + + /// + /// Turns a friend's VR/BR from rksys.dat into rating points. Older WheelWizard versions stored full points instead, + /// so the default (5000) and anything above the highest stored rating are already points. + /// + public static uint FriendRatingFromRksys(uint stored) => + stored == DefaultRating || stored > MaxRating / FriendRatingScale ? stored : stored * FriendRatingScale; + + /// Turns rating points into the friend VR/BR stored in rksys.dat. + public static ushort FriendRatingToRksys(uint points) => (ushort)(Math.Min(points, MaxRating) / FriendRatingScale); + /// /// Returns the "focused" or currently active license/user as determined by the Settings. /// @@ -408,8 +423,8 @@ private void ParseFriends(LicenseProfile licenseProfile, int userOffset) var friend = new FriendProfile { - Vr = BigEndianBinary.BufferToUint16(_rksysData, currentOffset + 0x16), - Br = BigEndianBinary.BufferToUint16(_rksysData, currentOffset + 0x18), + Vr = FriendRatingFromRksys(BigEndianBinary.BufferToUint16(_rksysData, currentOffset + 0x16)), + Br = FriendRatingFromRksys(BigEndianBinary.BufferToUint16(_rksysData, currentOffset + 0x18)), FriendCode = friendCode, Wins = BigEndianBinary.BufferToUint16(_rksysData, currentOffset + 0x14), Losses = BigEndianBinary.BufferToUint16(_rksysData, currentOffset + 0x12), @@ -509,8 +524,8 @@ public OperationResult AddFriend(int userIndex, string friendCode, Mii friendMii friendCodeValue, friendProfileId, friendMiiDataResult.Value, - (ushort)Math.Min(vr, ushort.MaxValue), - (ushort)Math.Min(br, ushort.MaxValue) + FriendRatingToRksys(vr), + FriendRatingToRksys(br) ); var saveResult = SaveRksysToFile();