diff --git a/WheelWizard.Test/Features/FriendRatingResolverTests.cs b/WheelWizard.Test/Features/FriendRatingResolverTests.cs new file mode 100644 index 000000000..e461aaf18 --- /dev/null +++ b/WheelWizard.Test/Features/FriendRatingResolverTests.cs @@ -0,0 +1,360 @@ +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(br, friend.Br); + 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); + } + + [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] + 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/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.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); + } +} 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/FriendRatingResolver.cs b/WheelWizard/Features/WiiManagement/GameLicense/FriendRatingResolver.cs new file mode 100644 index 000000000..3837a9fac --- /dev/null +++ b/WheelWizard/Features/WiiManagement/GameLicense/FriendRatingResolver.cs @@ -0,0 +1,83 @@ +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)) + { + if (livePlayer.Br.HasValue) + { + friend.Br = (uint)Math.Max(livePlayer.Br.Value, 0); + _liveBrCache[friend.FriendCode] = friend.Br; + } + if (livePlayer.Vr.HasValue) + { + friend.Vr = (uint)Math.Max(livePlayer.Vr.Value, 0); + _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.LastAttemptAt < ApiVrCacheDuration) + continue; + } + + if (_pendingApiVrRequests.Add(friend.FriendCode)) + friendCodesToFetch.Add(friend.FriendCode); + } + + return friendCodesToFetch; + } + + /// + /// 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.TryGetValue(friendCode, out var cached); + _apiVrCache[friendCode] = (vr ?? cached.Vr, now); + _pendingApiVrRequests.Remove(friendCode); + } +} diff --git a/WheelWizard/Features/WiiManagement/GameLicense/FriendRatingService.cs b/WheelWizard/Features/WiiManagement/GameLicense/FriendRatingService.cs new file mode 100644 index 000000000..5692f7d0c --- /dev/null +++ b/WheelWizard/Features/WiiManagement/GameLicense/FriendRatingService.cs @@ -0,0 +1,67 @@ +using Microsoft.Extensions.Logging; +using WheelWizard.Models.RRInfo; +using WheelWizard.RrRooms; +using WheelWizard.Shared.Services; +using WheelWizard.WiiManagement.GameLicense.Domain; + +namespace WheelWizard.WiiManagement.GameLicense; + +/// Keeps 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/GameLicense/GameLicenseService.cs b/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs index a0117a23c..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 = 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), 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 d1097a77d..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; @@ -33,6 +34,7 @@ public partial class FriendsPage : UserControl, INotifyPropertyChanged, IPolling private static ListOrderCondition CurrentOrder = ListOrderCondition.IS_ONLINE; private ObservableCollection _friendlist = []; + private bool _isUnloaded; private LiveRoomsService LiveRooms { get; } @@ -42,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 @@ -61,6 +65,7 @@ public FriendsPage( IGameLicenseSingletonService gameLicenseService, IMiiDbService miiDbService, IApiCaller apiCaller, + FriendRatingService ratings, ISettingsManager settingsService ) { @@ -70,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; @@ -83,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(); @@ -128,7 +152,11 @@ 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; + var onlinePlayers = LiveRooms.CurrentRooms.SelectMany(room => room.Players); + Ratings.Refresh(friends, onlinePlayers); + + return friends.OrderByDescending(orderMethod).ToList(); } private void PopulateSortingList()