From d787084bf9a5f1476d44af9d035fa31cc694b740 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Asier=20Septi=C3=A9n?= Date: Wed, 23 Sep 2026 17:47:24 +0200 Subject: [PATCH 1/5] fix: friend VR/BR display now uses live rooms, RWFC API and rksys BR scaling VR/BR data saved in rksys.dat stores values dividing them by 100, so the friends list never got to display the last 2 digits of any VR correctly. This fix now overrides the values in the UI with accurate ratings using, in this order: 1.- live rooms (VR/BR for online friends) 2.- RWFC API (VR only, BR not supported) 3.- rksys scaling (BR, needs to be rescaled to account for the 2 digits lost at the end) --- .../GameLicense/Domain/PlayerProfileBase.cs | 28 ++++++- .../GameLicense/GameLicenseService.cs | 8 +- WheelWizard/Views/Pages/FriendsPage.axaml.cs | 74 ++++++++++++++++++- 3 files changed, 106 insertions(+), 4 deletions(-) diff --git a/WheelWizard/Features/WiiManagement/GameLicense/Domain/PlayerProfileBase.cs b/WheelWizard/Features/WiiManagement/GameLicense/Domain/PlayerProfileBase.cs index b445bb120..ff84e2aa8 100644 --- a/WheelWizard/Features/WiiManagement/GameLicense/Domain/PlayerProfileBase.cs +++ b/WheelWizard/Features/WiiManagement/GameLicense/Domain/PlayerProfileBase.cs @@ -8,8 +8,32 @@ 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; } + private uint _vr; + private uint _br; + + public required uint Vr + { + get => _vr; + set + { + if (_vr == value) + return; + _vr = value; + OnPropertyChanged(nameof(Vr)); + } + } + + public required uint Br + { + get => _br; + set + { + if (_br == value) + return; + _br = value; + OnPropertyChanged(nameof(Br)); + } + } public required uint RegionId { get; init; } public required Mii? Mii { get; set; } diff --git a/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs b/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs index a0117a23c..6ee12da7b 100644 --- a/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs +++ b/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs @@ -373,7 +373,7 @@ private void ParseFriends(LicenseProfile licenseProfile, int userOffset) var friend = new FriendProfile { Vr = BigEndianBinary.BufferToUint16(_rksysData, currentOffset + 0x16), - Br = BigEndianBinary.BufferToUint16(_rksysData, currentOffset + 0x18), + Br = FriendBrFromRksys(BigEndianBinary.BufferToUint16(_rksysData, currentOffset + 0x18)), FriendCode = friendCode, Wins = BigEndianBinary.BufferToUint16(_rksysData, currentOffset + 0x14), Losses = BigEndianBinary.BufferToUint16(_rksysData, currentOffset + 0x12), @@ -484,6 +484,12 @@ public OperationResult AddFriend(int userIndex, string friendCode, Mii friendMii return ParseUsers(); } + private const uint DefaultFriendRating = 5000; + private const uint MaxRksysFriendRating = 1_000_000 / 100; + + // The game stores BR / 100; WheelWizard only ever writes the default 5000 + private static uint FriendBrFromRksys(uint rawBr) => rawBr == DefaultFriendRating || rawBr > MaxRksysFriendRating ? rawBr : rawBr * 100; + private bool CheckForMiiData(int offset) { if (_rksysData == null || offset < 0 || offset + MiiSize > _rksysData.Length) diff --git a/WheelWizard/Views/Pages/FriendsPage.axaml.cs b/WheelWizard/Views/Pages/FriendsPage.axaml.cs index d1097a77d..502ac8066 100644 --- a/WheelWizard/Views/Pages/FriendsPage.axaml.cs +++ b/WheelWizard/Views/Pages/FriendsPage.axaml.cs @@ -32,6 +32,12 @@ public partial class FriendsPage : UserControl, INotifyPropertyChanged, IPolling // Though I do see the use in saving it when using the app so you can swap pages in the meantime private static ListOrderCondition CurrentOrder = ListOrderCondition.IS_ONLINE; + // rksys.dat VR/BR is unreliable, so prefer live rooms, then the API (VR only) + private static readonly TimeSpan ApiVrCacheDuration = TimeSpan.FromMinutes(5); + private static readonly Dictionary ApiVrCache = []; + private static readonly HashSet PendingApiVrRequests = []; + private static readonly Dictionary LiveBrCache = []; + private ObservableCollection _friendlist = []; private LiveRoomsService LiveRooms { get; } @@ -128,7 +134,73 @@ private List GetSortedPlayerList() ListOrderCondition.TOTAL_RACES => f => f.Losses + f.Wins, ListOrderCondition.IS_ONLINE or _ => f => f.IsOnline, }; - return GameLicenseService.ActiveCurrentFriends.OrderByDescending(orderMethod).ToList(); + var friends = GameLicenseService.ActiveCurrentFriends; + ApplyAccurateRatings(friends); + return friends.OrderByDescending(orderMethod).ToList(); + } + + private void ApplyAccurateRatings(List friends) + { + var onlinePlayers = RRLiveRooms + .Instance.CurrentRooms.SelectMany(room => room.Players) + .Where(player => !string.IsNullOrWhiteSpace(player.FriendCode)) + .GroupBy(player => player.FriendCode) + .ToDictionary(group => group.Key, group => group.First()); + + var friendCodesToFetch = new List(); + foreach (var friend in friends) + { + if (string.IsNullOrWhiteSpace(friend.FriendCode)) + continue; + + if (onlinePlayers.TryGetValue(friend.FriendCode, out var livePlayer) && livePlayer.Vr.HasValue) + { + friend.Vr = (uint)Math.Max(livePlayer.Vr.Value, 0); + if (livePlayer.Br.HasValue) + { + friend.Br = (uint)Math.Max(livePlayer.Br.Value, 0); + LiveBrCache[friend.FriendCode] = friend.Br; + } + ApiVrCache[friend.FriendCode] = (friend.Vr, DateTime.Now); + continue; + } + + if (LiveBrCache.TryGetValue(friend.FriendCode, out var cachedBr)) + friend.Br = cachedBr; + + if (ApiVrCache.TryGetValue(friend.FriendCode, out var cached)) + { + if (cached.Vr.HasValue) + friend.Vr = cached.Vr.Value; + if (DateTime.Now - cached.FetchedAt < ApiVrCacheDuration) + continue; + } + + if (PendingApiVrRequests.Add(friend.FriendCode)) + friendCodesToFetch.Add(friend.FriendCode); + } + + if (friendCodesToFetch.Count > 0) + _ = FetchApiVrAsync(friendCodesToFetch); + } + + private async Task FetchApiVrAsync(List friendCodes) + { + var anyUpdated = false; + // Sequential to avoid flooding the API + foreach (var friendCode in friendCodes) + { + var profileResult = await ApiCaller.CallApiAsync(rwfcApi => rwfcApi.GetPlayerProfileAsync(friendCode)); + uint? vr = profileResult.IsSuccess && profileResult.Value is { Vr: > 0 } profile ? (uint)profile.Vr : null; + + // Failures are cached too, to avoid retrying on every update + ApiVrCache[friendCode] = (vr, DateTime.Now); + PendingApiVrRequests.Remove(friendCode); + anyUpdated |= vr.HasValue; + } + + if (anyUpdated) + UpdateFriendList(); } private void PopulateSortingList() From af971e1b834a3ae4cd6ab518b9730fd6648038f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Asier=20Septi=C3=A9n?= Date: Wed, 23 Sep 2026 18:59:12 +0200 Subject: [PATCH 2/5] Moved new implementation to FriendRatingResolver class Added new class called FriendRatingResolver to cover for the VR/BR truncation bug fix --- .../GameLicense/FriendRatingResolver.cs | 79 +++++++++++++++++++ .../GameLicense/GameLicenseService.cs | 8 +- WheelWizard/Views/Pages/FriendsPage.axaml.cs | 63 ++------------- 3 files changed, 88 insertions(+), 62 deletions(-) create mode 100644 WheelWizard/Features/WiiManagement/GameLicense/FriendRatingResolver.cs diff --git a/WheelWizard/Features/WiiManagement/GameLicense/FriendRatingResolver.cs b/WheelWizard/Features/WiiManagement/GameLicense/FriendRatingResolver.cs new file mode 100644 index 000000000..282d01f2a --- /dev/null +++ b/WheelWizard/Features/WiiManagement/GameLicense/FriendRatingResolver.cs @@ -0,0 +1,79 @@ +using WheelWizard.Models; +using WheelWizard.Models.RRInfo; +using WheelWizard.WiiManagement.GameLicense.Domain; + +namespace WheelWizard.WiiManagement.GameLicense; + +// rksys.dat VR/BR is unreliable, so prefer live rooms, then the API (VR only) +public class FriendRatingResolver +{ + public const uint DefaultFriendRating = 5000; + public const uint MaxRksysFriendRating = 1_000_000 / 100; + public static readonly TimeSpan ApiVrCacheDuration = TimeSpan.FromMinutes(5); + + private readonly Dictionary _apiVrCache = []; + private readonly HashSet _pendingApiVrRequests = []; + private readonly Dictionary _liveBrCache = []; + + // The game stores BR / 100; WheelWizard only ever writes the default 5000 + public static uint BrFromRksys(uint rawBr) => rawBr == DefaultFriendRating || rawBr > MaxRksysFriendRating ? rawBr : rawBr * 100; + + public static uint? VrFromApiProfile(PlayerProfileResponse? profile) => profile is { Vr: > 0 } ? (uint)profile.Vr : null; + + /// + /// Applies the most accurate known VR/BR to each friend. + /// + /// Friend codes whose VR should be fetched from the API. + public List Apply(IEnumerable friends, IEnumerable onlinePlayers, DateTime now) + { + var onlineByFriendCode = onlinePlayers + .Where(player => !string.IsNullOrWhiteSpace(player.FriendCode)) + .GroupBy(player => player.FriendCode) + .ToDictionary(group => group.Key, group => group.First()); + + var friendCodesToFetch = new List(); + foreach (var friend in friends) + { + if (string.IsNullOrWhiteSpace(friend.FriendCode)) + continue; + + if (onlineByFriendCode.TryGetValue(friend.FriendCode, out var livePlayer) && livePlayer.Vr.HasValue) + { + friend.Vr = (uint)Math.Max(livePlayer.Vr.Value, 0); + if (livePlayer.Br.HasValue) + { + friend.Br = (uint)Math.Max(livePlayer.Br.Value, 0); + _liveBrCache[friend.FriendCode] = friend.Br; + } + _apiVrCache[friend.FriendCode] = (friend.Vr, now); + continue; + } + + if (_liveBrCache.TryGetValue(friend.FriendCode, out var cachedBr)) + friend.Br = cachedBr; + + if (_apiVrCache.TryGetValue(friend.FriendCode, out var cached)) + { + if (cached.Vr.HasValue) + friend.Vr = cached.Vr.Value; + if (now - cached.FetchedAt < ApiVrCacheDuration) + continue; + } + + if (_pendingApiVrRequests.Add(friend.FriendCode)) + friendCodesToFetch.Add(friend.FriendCode); + } + + return friendCodesToFetch; + } + + /// + /// Stores the result of an API request. A null VR marks a failed request, which is cached too + /// to avoid retrying on every update. + /// + public void StoreApiVr(string friendCode, uint? vr, DateTime now) + { + _apiVrCache[friendCode] = (vr, now); + _pendingApiVrRequests.Remove(friendCode); + } +} diff --git a/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs b/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs index 6ee12da7b..845fd9f87 100644 --- a/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs +++ b/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs @@ -373,7 +373,7 @@ private void ParseFriends(LicenseProfile licenseProfile, int userOffset) var friend = new FriendProfile { Vr = BigEndianBinary.BufferToUint16(_rksysData, currentOffset + 0x16), - Br = FriendBrFromRksys(BigEndianBinary.BufferToUint16(_rksysData, currentOffset + 0x18)), + Br = FriendRatingResolver.BrFromRksys(BigEndianBinary.BufferToUint16(_rksysData, currentOffset + 0x18)), FriendCode = friendCode, Wins = BigEndianBinary.BufferToUint16(_rksysData, currentOffset + 0x14), Losses = BigEndianBinary.BufferToUint16(_rksysData, currentOffset + 0x12), @@ -484,12 +484,6 @@ public OperationResult AddFriend(int userIndex, string friendCode, Mii friendMii return ParseUsers(); } - private const uint DefaultFriendRating = 5000; - private const uint MaxRksysFriendRating = 1_000_000 / 100; - - // The game stores BR / 100; WheelWizard only ever writes the default 5000 - private static uint FriendBrFromRksys(uint rawBr) => rawBr == DefaultFriendRating || rawBr > MaxRksysFriendRating ? rawBr : rawBr * 100; - private bool CheckForMiiData(int offset) { if (_rksysData == null || offset < 0 || offset + MiiSize > _rksysData.Length) diff --git a/WheelWizard/Views/Pages/FriendsPage.axaml.cs b/WheelWizard/Views/Pages/FriendsPage.axaml.cs index 502ac8066..f8d36e2de 100644 --- a/WheelWizard/Views/Pages/FriendsPage.axaml.cs +++ b/WheelWizard/Views/Pages/FriendsPage.axaml.cs @@ -32,11 +32,8 @@ public partial class FriendsPage : UserControl, INotifyPropertyChanged, IPolling // Though I do see the use in saving it when using the app so you can swap pages in the meantime private static ListOrderCondition CurrentOrder = ListOrderCondition.IS_ONLINE; - // rksys.dat VR/BR is unreliable, so prefer live rooms, then the API (VR only) - private static readonly TimeSpan ApiVrCacheDuration = TimeSpan.FromMinutes(5); - private static readonly Dictionary ApiVrCache = []; - private static readonly HashSet PendingApiVrRequests = []; - private static readonly Dictionary LiveBrCache = []; + // Static so the caches survive page swaps + private static readonly FriendRatingResolver RatingResolver = new(); private ObservableCollection _friendlist = []; @@ -135,53 +132,12 @@ private List GetSortedPlayerList() ListOrderCondition.IS_ONLINE or _ => f => f.IsOnline, }; var friends = GameLicenseService.ActiveCurrentFriends; - ApplyAccurateRatings(friends); - return friends.OrderByDescending(orderMethod).ToList(); - } - - private void ApplyAccurateRatings(List friends) - { - var onlinePlayers = RRLiveRooms - .Instance.CurrentRooms.SelectMany(room => room.Players) - .Where(player => !string.IsNullOrWhiteSpace(player.FriendCode)) - .GroupBy(player => player.FriendCode) - .ToDictionary(group => group.Key, group => group.First()); - - var friendCodesToFetch = new List(); - foreach (var friend in friends) - { - if (string.IsNullOrWhiteSpace(friend.FriendCode)) - continue; - - if (onlinePlayers.TryGetValue(friend.FriendCode, out var livePlayer) && livePlayer.Vr.HasValue) - { - friend.Vr = (uint)Math.Max(livePlayer.Vr.Value, 0); - if (livePlayer.Br.HasValue) - { - friend.Br = (uint)Math.Max(livePlayer.Br.Value, 0); - LiveBrCache[friend.FriendCode] = friend.Br; - } - ApiVrCache[friend.FriendCode] = (friend.Vr, DateTime.Now); - continue; - } - - if (LiveBrCache.TryGetValue(friend.FriendCode, out var cachedBr)) - friend.Br = cachedBr; - - if (ApiVrCache.TryGetValue(friend.FriendCode, out var cached)) - { - if (cached.Vr.HasValue) - friend.Vr = cached.Vr.Value; - if (DateTime.Now - cached.FetchedAt < ApiVrCacheDuration) - continue; - } - - if (PendingApiVrRequests.Add(friend.FriendCode)) - friendCodesToFetch.Add(friend.FriendCode); - } - + var onlinePlayers = RRLiveRooms.Instance.CurrentRooms.SelectMany(room => room.Players); + var friendCodesToFetch = RatingResolver.Apply(friends, onlinePlayers, DateTime.Now); if (friendCodesToFetch.Count > 0) _ = FetchApiVrAsync(friendCodesToFetch); + + return friends.OrderByDescending(orderMethod).ToList(); } private async Task FetchApiVrAsync(List friendCodes) @@ -191,11 +147,8 @@ private async Task FetchApiVrAsync(List friendCodes) foreach (var friendCode in friendCodes) { var profileResult = await ApiCaller.CallApiAsync(rwfcApi => rwfcApi.GetPlayerProfileAsync(friendCode)); - uint? vr = profileResult.IsSuccess && profileResult.Value is { Vr: > 0 } profile ? (uint)profile.Vr : null; - - // Failures are cached too, to avoid retrying on every update - ApiVrCache[friendCode] = (vr, DateTime.Now); - PendingApiVrRequests.Remove(friendCode); + var vr = profileResult.IsSuccess ? FriendRatingResolver.VrFromApiProfile(profileResult.Value) : null; + RatingResolver.StoreApiVr(friendCode, vr, DateTime.Now); anyUpdated |= vr.HasValue; } From bfb42ddf775fe4de8946360e4f28532590c9ded3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Asier=20Septi=C3=A9n?= Date: Wed, 23 Sep 2026 19:00:14 +0200 Subject: [PATCH 3/5] Tests made for friend VR/BR truncation bug fix Made relevant tests for bug fix --- .../Features/FriendRatingResolverTests.cs | 337 ++++++++++++++++++ .../Features/PlayerProfileBaseTests.cs | 68 ++++ 2 files changed, 405 insertions(+) create mode 100644 WheelWizard.Test/Features/FriendRatingResolverTests.cs create mode 100644 WheelWizard.Test/Features/PlayerProfileBaseTests.cs diff --git a/WheelWizard.Test/Features/FriendRatingResolverTests.cs b/WheelWizard.Test/Features/FriendRatingResolverTests.cs new file mode 100644 index 000000000..a670e492b --- /dev/null +++ b/WheelWizard.Test/Features/FriendRatingResolverTests.cs @@ -0,0 +1,337 @@ +using WheelWizard.Models.RRInfo; +using WheelWizard.WiiManagement.GameLicense; +using WheelWizard.WiiManagement.GameLicense.Domain; + +namespace WheelWizard.Test.Features; + +public class FriendRatingResolverTests +{ + private const string FriendCode = "1234-5678-9012"; + private const string OtherFriendCode = "0000-0000-0001"; + private const uint MaxRating = 1_000_000; + private static readonly DateTime Now = new(2026, 1, 1, 12, 0, 0); + + // VR/BR pairs spread over the whole valid range, including 16-bit and default-rating edges + public static TheoryData Ratings => + new() + { + { 1, 1 }, + { 99, 100 }, + { 4999, 5000 }, + { 5000, 5001 }, + { 9999, 10000 }, + { 65535, 65536 }, + { 100_001, 99_999 }, + { 543_210, 876_543 }, + { MaxRating, MaxRating }, + }; + + // What the game writes to rksys.dat for a given rating (integer part of rating / 100) + private static uint GameRksysValue(uint rating) => rating / 100; + + private static FriendProfile CreateFriend(uint vr, uint br, string friendCode = FriendCode) => + new() + { + FriendCode = friendCode, + Vr = vr, + Br = br, + RegionId = 0, + Mii = null, + Wins = 0, + Losses = 0, + CountryCode = 0, + }; + + private static FriendProfile CreateStaleFriend(uint vr, uint br, string friendCode = FriendCode) => + CreateFriend(GameRksysValue(vr), GameRksysValue(br), friendCode); + + private static RrPlayer CreatePlayer(int? vr, int? br, string friendCode = FriendCode) => + new() + { + Pid = "1", + Name = "Player", + FriendCode = friendCode, + Vr = vr, + Br = br, + }; + + #region BrFromRksys + + [Fact] + public void BrFromRksys_HandlesEveryPossibleRksysValue() + { + for (uint rawBr = 0; rawBr <= ushort.MaxValue; rawBr++) + { + var br = FriendRatingResolver.BrFromRksys(rawBr); + + if (rawBr == FriendRatingResolver.DefaultFriendRating || rawBr > FriendRatingResolver.MaxRksysFriendRating) + Assert.Equal(rawBr, br); + else + { + Assert.Equal(rawBr * 100, br); + Assert.InRange(br, 0u, MaxRating); + } + } + } + + [Theory] + [MemberData(nameof(Ratings))] + public void BrFromRksys_RestoresGameWrittenBr_ToWithin100Points(uint _, uint br) + { + var rawBr = GameRksysValue(br); + if (rawBr == FriendRatingResolver.DefaultFriendRating) + return; // Ambiguous with the default rating, covered separately + + var restored = FriendRatingResolver.BrFromRksys(rawBr); + + Assert.InRange(restored, br - br % 100, br); + } + + [Fact] + public void BrFromRksys_KeepsDefaultRating() + { + Assert.Equal(FriendRatingResolver.DefaultFriendRating, FriendRatingResolver.BrFromRksys(FriendRatingResolver.DefaultFriendRating)); + } + + #endregion + + #region VrFromApiProfile + + [Theory] + [MemberData(nameof(Ratings))] + public void VrFromApiProfile_ReturnsVr_ForAnyValidRating(uint vr, uint _) + { + Assert.Equal(vr, FriendRatingResolver.VrFromApiProfile(new() { Vr = (int)vr })); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(int.MinValue)] + public void VrFromApiProfile_ReturnsNull_ForNonPositiveVr(int vr) + { + Assert.Null(FriendRatingResolver.VrFromApiProfile(new() { Vr = vr })); + } + + [Fact] + public void VrFromApiProfile_ReturnsNull_ForNullProfile() + { + Assert.Null(FriendRatingResolver.VrFromApiProfile(null)); + } + + #endregion + + #region Apply - live rooms + + [Theory] + [MemberData(nameof(Ratings))] + public void Apply_UsesLiveVrAndBr_ForOnlineFriend(uint vr, uint br) + { + var resolver = new FriendRatingResolver(); + var friend = CreateStaleFriend(vr, br); + + var toFetch = resolver.Apply([friend], [CreatePlayer((int)vr, (int)br)], Now); + + Assert.Equal(vr, friend.Vr); + Assert.Equal(br, friend.Br); + Assert.Empty(toFetch); + } + + [Theory] + [MemberData(nameof(Ratings))] + public void Apply_KeepsBr_WhenLivePlayerHasNoBr(uint vr, uint br) + { + var resolver = new FriendRatingResolver(); + var friend = CreateFriend(GameRksysValue(vr), br); + + resolver.Apply([friend], [CreatePlayer((int)vr, null)], Now); + + Assert.Equal(vr, friend.Vr); + Assert.Equal(br, friend.Br); + } + + [Fact] + public void Apply_ClampsNegativeLiveValuesToZero() + { + var resolver = new FriendRatingResolver(); + var friend = CreateFriend(FriendRatingResolver.DefaultFriendRating, FriendRatingResolver.DefaultFriendRating); + + resolver.Apply([friend], [CreatePlayer(-1, -1)], Now); + + Assert.Equal(0u, friend.Vr); + Assert.Equal(0u, friend.Br); + } + + [Theory] + [MemberData(nameof(Ratings))] + public void Apply_RequestsApiVr_WhenLivePlayerHasNoVr(uint vr, uint br) + { + var resolver = new FriendRatingResolver(); + var friend = CreateStaleFriend(vr, br); + + var toFetch = resolver.Apply([friend], [CreatePlayer(null, (int)br)], Now); + + Assert.Equal(GameRksysValue(vr), friend.Vr); + Assert.Equal([FriendCode], toFetch); + } + + [Theory] + [MemberData(nameof(Ratings))] + public void Apply_IgnoresLivePlayersWithOtherFriendCodes(uint vr, uint br) + { + var resolver = new FriendRatingResolver(); + var friend = CreateStaleFriend(vr, br); + + var toFetch = resolver.Apply([friend], [CreatePlayer((int)vr, (int)br, OtherFriendCode)], Now); + + Assert.Equal(GameRksysValue(vr), friend.Vr); + Assert.Equal(GameRksysValue(br), friend.Br); + Assert.Equal([FriendCode], toFetch); + } + + [Fact] + public void Apply_UsesFirstLivePlayer_WhenFriendCodeIsDuplicated() + { + var resolver = new FriendRatingResolver(); + var friend = CreateFriend(1, 1); + + resolver.Apply([friend], [CreatePlayer(1234, 5678), CreatePlayer(8765, 4321)], Now); + + Assert.Equal(1234u, friend.Vr); + Assert.Equal(5678u, friend.Br); + } + + [Theory] + [MemberData(nameof(Ratings))] + public void Apply_KeepsLiveRatings_AfterFriendGoesOffline(uint vr, uint br) + { + var resolver = new FriendRatingResolver(); + resolver.Apply([CreateStaleFriend(vr, br)], [CreatePlayer((int)vr, (int)br)], Now); + + // The license service re-creates friends from rksys.dat on every reload + var reloadedFriend = CreateStaleFriend(vr, br); + var toFetch = resolver.Apply([reloadedFriend], [], Now.AddMinutes(1)); + + Assert.Equal(vr, reloadedFriend.Vr); + Assert.Equal(br, reloadedFriend.Br); + Assert.Empty(toFetch); + } + + #endregion + + #region Apply - API cache + + [Theory] + [MemberData(nameof(Ratings))] + public void Apply_RequestsApiVr_AndKeepsRksysValues_ForOfflineFriendWithoutCache(uint vr, uint br) + { + var resolver = new FriendRatingResolver(); + var friend = CreateStaleFriend(vr, br); + + var toFetch = resolver.Apply([friend], [], Now); + + Assert.Equal([FriendCode], toFetch); + Assert.Equal(GameRksysValue(vr), friend.Vr); + Assert.Equal(GameRksysValue(br), friend.Br); + } + + [Fact] + public void Apply_DoesNotRequestAgain_WhileRequestIsPending() + { + var resolver = new FriendRatingResolver(); + + resolver.Apply([CreateFriend(1, 1)], [], Now); + var toFetch = resolver.Apply([CreateFriend(1, 1)], [], Now); + + Assert.Empty(toFetch); + } + + [Theory] + [MemberData(nameof(Ratings))] + public void Apply_UsesCachedApiVr_AndKeepsBr_WithinCacheDuration(uint vr, uint br) + { + var resolver = new FriendRatingResolver(); + resolver.Apply([CreateStaleFriend(vr, br)], [], Now); + resolver.StoreApiVr(FriendCode, vr, Now); + + var friend = CreateStaleFriend(vr, br); + var toFetch = resolver.Apply([friend], [], Now + FriendRatingResolver.ApiVrCacheDuration - TimeSpan.FromSeconds(1)); + + Assert.Equal(vr, friend.Vr); + Assert.Equal(GameRksysValue(br), friend.Br); + Assert.Empty(toFetch); + } + + [Theory] + [MemberData(nameof(Ratings))] + public void Apply_RequestsApiVrAgain_AfterCacheExpires_AndKeepsCachedValueMeanwhile(uint vr, uint br) + { + var resolver = new FriendRatingResolver(); + resolver.Apply([CreateStaleFriend(vr, br)], [], Now); + resolver.StoreApiVr(FriendCode, vr, Now); + + var friend = CreateStaleFriend(vr, br); + var toFetch = resolver.Apply([friend], [], Now + FriendRatingResolver.ApiVrCacheDuration); + + Assert.Equal(vr, friend.Vr); + Assert.Equal([FriendCode], toFetch); + } + + [Theory] + [MemberData(nameof(Ratings))] + public void Apply_KeepsRksysVr_AndDoesNotRetry_AfterFailedRequest(uint vr, uint br) + { + var resolver = new FriendRatingResolver(); + resolver.Apply([CreateStaleFriend(vr, br)], [], Now); + resolver.StoreApiVr(FriendCode, null, Now); + + var friend = CreateStaleFriend(vr, br); + var toFetch = resolver.Apply([friend], [], Now.AddMinutes(1)); + + Assert.Equal(GameRksysValue(vr), friend.Vr); + Assert.Empty(toFetch); + } + + [Fact] + public void Apply_RetriesFailedRequest_AfterCacheExpires() + { + var resolver = new FriendRatingResolver(); + resolver.Apply([CreateFriend(1, 1)], [], Now); + resolver.StoreApiVr(FriendCode, null, Now); + + var toFetch = resolver.Apply([CreateFriend(1, 1)], [], Now + FriendRatingResolver.ApiVrCacheDuration); + + Assert.Equal([FriendCode], toFetch); + } + + #endregion + + [Fact] + public void Apply_SkipsFriendsWithoutFriendCode() + { + var resolver = new FriendRatingResolver(); + var friend = CreateFriend(1, 1, friendCode: ""); + + var toFetch = resolver.Apply([friend], [CreatePlayer(1234, 5678, friendCode: "")], Now); + + Assert.Equal(1u, friend.Vr); + Assert.Equal(1u, friend.Br); + Assert.Empty(toFetch); + } + + [Fact] + public void Apply_ResolvesEachFriendIndependently() + { + var resolver = new FriendRatingResolver(); + var onlineFriend = CreateFriend(1, 1); + var offlineFriend = CreateFriend(2, 2, OtherFriendCode); + + var toFetch = resolver.Apply([onlineFriend, offlineFriend], [CreatePlayer(1234, 5678)], Now); + + Assert.Equal(1234u, onlineFriend.Vr); + Assert.Equal(5678u, onlineFriend.Br); + Assert.Equal(2u, offlineFriend.Vr); + Assert.Equal(2u, offlineFriend.Br); + Assert.Equal([OtherFriendCode], toFetch); + } +} diff --git a/WheelWizard.Test/Features/PlayerProfileBaseTests.cs b/WheelWizard.Test/Features/PlayerProfileBaseTests.cs new file mode 100644 index 000000000..2d654f715 --- /dev/null +++ b/WheelWizard.Test/Features/PlayerProfileBaseTests.cs @@ -0,0 +1,68 @@ +using WheelWizard.WiiManagement.GameLicense.Domain; + +namespace WheelWizard.Test.Features; + +public class PlayerProfileBaseTests +{ + private const uint InitialRating = 5000; + + public static TheoryData Ratings => new() { 0, 1, 4999, 5001, 65535, 65536, 543_210, 1_000_000 }; + + private static FriendProfile CreateFriend() => + new() + { + FriendCode = "1234-5678-9012", + Vr = InitialRating, + Br = InitialRating, + RegionId = 0, + Mii = null, + Wins = 0, + Losses = 0, + CountryCode = 0, + }; + + private static List TrackPropertyChanges(FriendProfile friend) + { + var changed = new List(); + friend.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + return changed; + } + + [Theory] + [MemberData(nameof(Ratings))] + public void Vr_RaisesPropertyChanged_WhenValueChanges(uint vr) + { + var friend = CreateFriend(); + var changed = TrackPropertyChanges(friend); + + friend.Vr = vr; + + Assert.Equal(vr, friend.Vr); + Assert.Equal([nameof(FriendProfile.Vr)], changed); + } + + [Theory] + [MemberData(nameof(Ratings))] + public void Br_RaisesPropertyChanged_WhenValueChanges(uint br) + { + var friend = CreateFriend(); + var changed = TrackPropertyChanges(friend); + + friend.Br = br; + + Assert.Equal(br, friend.Br); + Assert.Equal([nameof(FriendProfile.Br)], changed); + } + + [Fact] + public void VrAndBr_DoNotRaisePropertyChanged_WhenValueIsTheSame() + { + var friend = CreateFriend(); + var changed = TrackPropertyChanges(friend); + + friend.Vr = InitialRating; + friend.Br = InitialRating; + + Assert.Empty(changed); + } +} From 8d87397747fd1f1a6d73843835b7334787f152f4 Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:39:18 +0200 Subject: [PATCH 4/5] task: align friend ratings with owned services --- .../Features/FriendRatingResolverTests.cs | 1 + .../Features/FriendRatingServiceTests.cs | 47 +++++++++++++ .../GameLicense/FriendRatingResolver.cs | 11 +-- .../GameLicense/FriendRatingService.cs | 67 +++++++++++++++++++ .../WiiManagement/WiiManagementExtensions.cs | 1 + WheelWizard/Views/Pages/FriendsPage.axaml.cs | 51 +++++++------- 6 files changed, 150 insertions(+), 28 deletions(-) create mode 100644 WheelWizard.Test/Features/FriendRatingServiceTests.cs create mode 100644 WheelWizard/Features/WiiManagement/GameLicense/FriendRatingService.cs diff --git a/WheelWizard.Test/Features/FriendRatingResolverTests.cs b/WheelWizard.Test/Features/FriendRatingResolverTests.cs index a670e492b..655b7d510 100644 --- a/WheelWizard.Test/Features/FriendRatingResolverTests.cs +++ b/WheelWizard.Test/Features/FriendRatingResolverTests.cs @@ -172,6 +172,7 @@ public void Apply_RequestsApiVr_WhenLivePlayerHasNoVr(uint vr, uint br) var toFetch = resolver.Apply([friend], [CreatePlayer(null, (int)br)], Now); Assert.Equal(GameRksysValue(vr), friend.Vr); + Assert.Equal(br, friend.Br); Assert.Equal([FriendCode], toFetch); } diff --git a/WheelWizard.Test/Features/FriendRatingServiceTests.cs b/WheelWizard.Test/Features/FriendRatingServiceTests.cs new file mode 100644 index 000000000..e8098919c --- /dev/null +++ b/WheelWizard.Test/Features/FriendRatingServiceTests.cs @@ -0,0 +1,47 @@ +using System.Linq.Expressions; +using Microsoft.Extensions.Logging.Abstractions; +using WheelWizard.Models; +using WheelWizard.RrRooms; +using WheelWizard.Shared; +using WheelWizard.Shared.Services; +using WheelWizard.WiiManagement.GameLicense; +using WheelWizard.WiiManagement.GameLicense.Domain; + +namespace WheelWizard.Test.Features; + +public class FriendRatingServiceTests +{ + [Fact] + public async Task ApiResultSurvivesFriendReloadAndDoesNotRequestAgainWithinCacheDuration() + { + var api = Substitute.For>(); + var response = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + api.CallApiAsync(Arg.Any>>>()).Returns(response.Task); + var ratings = new FriendRatingService(api, TimeProvider.System, NullLogger.Instance); + var changed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + ratings.RatingsChanged += () => changed.TrySetResult(); + + ratings.Refresh([CreateFriend()], []); + response.SetResult(Ok(new PlayerProfileResponse { Vr = 29367 })); + await changed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + var reloaded = CreateFriend(); + ratings.Refresh([reloaded], []); + + Assert.Equal(29367u, reloaded.Vr); + await api.Received(1).CallApiAsync(Arg.Any>>>()); + } + + private static FriendProfile CreateFriend() => + new() + { + FriendCode = "1234-5678-9012", + Vr = 293, + Br = 5000, + RegionId = 0, + Mii = null, + Wins = 0, + Losses = 0, + CountryCode = 0, + }; +} diff --git a/WheelWizard/Features/WiiManagement/GameLicense/FriendRatingResolver.cs b/WheelWizard/Features/WiiManagement/GameLicense/FriendRatingResolver.cs index 282d01f2a..29be6709a 100644 --- a/WheelWizard/Features/WiiManagement/GameLicense/FriendRatingResolver.cs +++ b/WheelWizard/Features/WiiManagement/GameLicense/FriendRatingResolver.cs @@ -37,16 +37,19 @@ public List Apply(IEnumerable friends, IEnumerableKeeps friend ratings and API requests alive across page navigation. +public sealed class FriendRatingService(IApiCaller apiCaller, TimeProvider timeProvider, ILogger logger) +{ + private readonly FriendRatingResolver _resolver = new(); + private readonly Lock _gate = new(); + private readonly SemaphoreSlim _requestGate = new(1, 1); + + public event Action? RatingsChanged; + + public void Refresh(IEnumerable friends, IEnumerable onlinePlayers) + { + List missing; + lock (_gate) + missing = _resolver.Apply(friends, onlinePlayers, timeProvider.GetUtcNow().UtcDateTime); + + if (missing.Count > 0) + _ = FetchApiVrAsync(missing); + } + + private async Task FetchApiVrAsync(List friendCodes) + { + await _requestGate.WaitAsync(); + try + { + foreach (var friendCode in friendCodes) + { + uint? vr = null; + try + { + var result = await apiCaller.CallApiAsync(api => api.GetPlayerProfileAsync(friendCode)); + if (result.IsSuccess) + vr = FriendRatingResolver.VrFromApiProfile(result.Value); + } + catch (Exception exception) + { + logger.LogError(exception, "Could not fetch VR for friend {FriendCode}", friendCode); + } + + lock (_gate) + _resolver.StoreApiVr(friendCode, vr, timeProvider.GetUtcNow().UtcDateTime); + if (vr.HasValue) + { + try + { + RatingsChanged?.Invoke(); + } + catch (Exception exception) + { + logger.LogError(exception, "Could not notify listeners about updated friend ratings"); + } + } + } + } + finally + { + _requestGate.Release(); + } + } +} diff --git a/WheelWizard/Features/WiiManagement/WiiManagementExtensions.cs b/WheelWizard/Features/WiiManagement/WiiManagementExtensions.cs index 564435f3d..2107ca08a 100644 --- a/WheelWizard/Features/WiiManagement/WiiManagementExtensions.cs +++ b/WheelWizard/Features/WiiManagement/WiiManagementExtensions.cs @@ -14,6 +14,7 @@ public static IServiceCollection AddWiiManagement(this IServiceCollection servic services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); return services; } } diff --git a/WheelWizard/Views/Pages/FriendsPage.axaml.cs b/WheelWizard/Views/Pages/FriendsPage.axaml.cs index f8d36e2de..e3d34274e 100644 --- a/WheelWizard/Views/Pages/FriendsPage.axaml.cs +++ b/WheelWizard/Views/Pages/FriendsPage.axaml.cs @@ -3,6 +3,7 @@ using Avalonia.Controls; using Avalonia.Input.Platform; using Avalonia.Interactivity; +using Avalonia.Threading; using WheelWizard.Models; using WheelWizard.RrRooms; using WheelWizard.Settings; @@ -32,10 +33,8 @@ public partial class FriendsPage : UserControl, INotifyPropertyChanged, IPolling // Though I do see the use in saving it when using the app so you can swap pages in the meantime private static ListOrderCondition CurrentOrder = ListOrderCondition.IS_ONLINE; - // Static so the caches survive page swaps - private static readonly FriendRatingResolver RatingResolver = new(); - private ObservableCollection _friendlist = []; + private bool _isUnloaded; private LiveRoomsService LiveRooms { get; } @@ -45,6 +44,8 @@ public partial class FriendsPage : UserControl, INotifyPropertyChanged, IPolling private IApiCaller ApiCaller { get; } + private FriendRatingService Ratings { get; } + private ISettingsManager SettingsService { get; } public ObservableCollection FriendList @@ -64,6 +65,7 @@ public FriendsPage( IGameLicenseSingletonService gameLicenseService, IMiiDbService miiDbService, IApiCaller apiCaller, + FriendRatingService ratings, ISettingsManager settingsService ) { @@ -73,9 +75,13 @@ ISettingsManager settingsService GameLicenseService = gameLicenseService; MiiDbService = miiDbService; ApiCaller = apiCaller; + Ratings = ratings; SettingsService = settingsService; InitializeComponent(); GameLicenseService.Subscribe(this); + LiveRooms.Subscribe(this); + Ratings.RatingsChanged += RatingsChanged; + Unloaded += FriendsPage_Unloaded; UpdateFriendList(); DataContext = this; @@ -86,11 +92,26 @@ ISettingsManager settingsService public void OnUpdate(ObservablePollingService sender) { - if (sender is not GameLicenseSingletonService) + if (sender is not GameLicenseSingletonService and not LiveRoomsService) return; UpdateFriendList(); } + private void RatingsChanged() => + Dispatcher.UIThread.Post(() => + { + if (!_isUnloaded) + UpdateFriendList(); + }); + + private void FriendsPage_Unloaded(object? sender, RoutedEventArgs e) + { + _isUnloaded = true; + GameLicenseService.Unsubscribe(this); + LiveRooms.Unsubscribe(this); + Ratings.RatingsChanged -= RatingsChanged; + } + private void UpdateFriendList() { var newList = GetSortedPlayerList(); @@ -132,30 +153,12 @@ private List GetSortedPlayerList() ListOrderCondition.IS_ONLINE or _ => f => f.IsOnline, }; var friends = GameLicenseService.ActiveCurrentFriends; - var onlinePlayers = RRLiveRooms.Instance.CurrentRooms.SelectMany(room => room.Players); - var friendCodesToFetch = RatingResolver.Apply(friends, onlinePlayers, DateTime.Now); - if (friendCodesToFetch.Count > 0) - _ = FetchApiVrAsync(friendCodesToFetch); + var onlinePlayers = LiveRooms.CurrentRooms.SelectMany(room => room.Players); + Ratings.Refresh(friends, onlinePlayers); return friends.OrderByDescending(orderMethod).ToList(); } - private async Task FetchApiVrAsync(List friendCodes) - { - var anyUpdated = false; - // Sequential to avoid flooding the API - foreach (var friendCode in friendCodes) - { - var profileResult = await ApiCaller.CallApiAsync(rwfcApi => rwfcApi.GetPlayerProfileAsync(friendCode)); - var vr = profileResult.IsSuccess ? FriendRatingResolver.VrFromApiProfile(profileResult.Value) : null; - RatingResolver.StoreApiVr(friendCode, vr, DateTime.Now); - anyUpdated |= vr.HasValue; - } - - if (anyUpdated) - UpdateFriendList(); - } - private void PopulateSortingList() { foreach (ListOrderCondition type in Enum.GetValues(typeof(ListOrderCondition))) From 5ee79473c0aca13970a916bce60b65f2bb5aae8d Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:58:53 +0200 Subject: [PATCH 5/5] fix: preserve cached friend VR after failed refresh --- .../Features/FriendRatingResolverTests.cs | 22 +++++++++++++++++++ .../GameLicense/FriendRatingResolver.cs | 11 +++++----- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/WheelWizard.Test/Features/FriendRatingResolverTests.cs b/WheelWizard.Test/Features/FriendRatingResolverTests.cs index 655b7d510..e461aaf18 100644 --- a/WheelWizard.Test/Features/FriendRatingResolverTests.cs +++ b/WheelWizard.Test/Features/FriendRatingResolverTests.cs @@ -305,6 +305,28 @@ public void Apply_RetriesFailedRequest_AfterCacheExpires() Assert.Equal([FriendCode], toFetch); } + [Fact] + public void Apply_KeepsLastGoodVr_AfterFailedRefreshAndFriendReload() + { + var resolver = new FriendRatingResolver(); + resolver.Apply([CreateStaleFriend(29367, 5000)], [], Now); + resolver.StoreApiVr(FriendCode, 29367, Now); + + var refreshAt = Now + FriendRatingResolver.ApiVrCacheDuration; + Assert.Equal([FriendCode], resolver.Apply([CreateStaleFriend(29367, 5000)], [], refreshAt)); + resolver.StoreApiVr(FriendCode, null, refreshAt); + + var reloadedFriend = CreateStaleFriend(29367, 5000); + var toFetch = resolver.Apply([reloadedFriend], [], refreshAt.AddMinutes(1)); + + Assert.Equal(29367u, reloadedFriend.Vr); + Assert.Empty(toFetch); + Assert.Equal( + [FriendCode], + resolver.Apply([CreateStaleFriend(29367, 5000)], [], refreshAt + FriendRatingResolver.ApiVrCacheDuration) + ); + } + #endregion [Fact] diff --git a/WheelWizard/Features/WiiManagement/GameLicense/FriendRatingResolver.cs b/WheelWizard/Features/WiiManagement/GameLicense/FriendRatingResolver.cs index 29be6709a..3837a9fac 100644 --- a/WheelWizard/Features/WiiManagement/GameLicense/FriendRatingResolver.cs +++ b/WheelWizard/Features/WiiManagement/GameLicense/FriendRatingResolver.cs @@ -11,7 +11,7 @@ public class FriendRatingResolver public const uint MaxRksysFriendRating = 1_000_000 / 100; public static readonly TimeSpan ApiVrCacheDuration = TimeSpan.FromMinutes(5); - private readonly Dictionary _apiVrCache = []; + private readonly Dictionary _apiVrCache = []; private readonly HashSet _pendingApiVrRequests = []; private readonly Dictionary _liveBrCache = []; @@ -59,7 +59,7 @@ public List Apply(IEnumerable friends, IEnumerable Apply(IEnumerable friends, IEnumerable - /// Stores the result of an API request. A null VR marks a failed request, which is cached too - /// to avoid retrying on every update. + /// Stores the result of an API request. A failed request keeps the last good VR while + /// its attempt time prevents another request until the cooldown expires. /// public void StoreApiVr(string friendCode, uint? vr, DateTime now) { - _apiVrCache[friendCode] = (vr, now); + _apiVrCache.TryGetValue(friendCode, out var cached); + _apiVrCache[friendCode] = (vr ?? cached.Vr, now); _pendingApiVrRequests.Remove(friendCode); } }