Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions WheelWizard.Test/Features/RrRooms/RrLeaderboardTests.cs
Original file line number Diff line number Diff line change
@@ -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<IRwfcApi> _apiCaller = Substitute.For<IApiCaller<IRwfcApi>>();
private readonly RrLeaderboardSingletonService _service;

public RrLeaderboardTests()
{
_service = new(_apiCaller, new MemoryCache(new MemoryCacheOptions()), NullLogger<RrLeaderboardSingletonService>.Instance);
}

private Task<OperationResult<PlayerProfileResponse>> ReceivedPlayerCalls(int count) =>
_apiCaller.Received(count).CallApiAsync(Arg.Any<Expression<Func<IRwfcApi, Task<PlayerProfileResponse>>>>());

[Fact]
public async Task GetPlayerAsync_FetchesPlayerOnce()
{
_apiCaller
.CallApiAsync(Arg.Any<Expression<Func<IRwfcApi, Task<PlayerProfileResponse>>>>())
.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<Expression<Func<IRwfcApi, Task<PlayerProfileResponse>>>>()).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<OperationResult<PlayerProfileResponse>>();
_apiCaller.CallApiAsync(Arg.Any<Expression<Func<IRwfcApi, Task<PlayerProfileResponse>>>>()).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);
}
}
34 changes: 34 additions & 0 deletions WheelWizard.Test/Features/WiiManagement/FriendRatingTests.cs
Original file line number Diff line number Diff line change
@@ -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));
}
}
16 changes: 16 additions & 0 deletions WheelWizard/Features/RrRooms/RrLeaderboardSingletonService.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using WheelWizard.Models;
using WheelWizard.Shared.Services;

namespace WheelWizard.RrRooms;

public interface IRrLeaderboardSingletonService
{
Task<OperationResult<List<RwfcLeaderboardEntry>>> GetTopPlayersAsync(int limit = 50);

/// <summary>A player's leaderboard data, cached for a few minutes (failures too, so they are not retried on every call).</summary>
Task<OperationResult<PlayerProfileResponse>> GetPlayerAsync(string friendCode);
}

public class RrLeaderboardSingletonService(
Expand All @@ -18,6 +22,7 @@ ILogger<RrLeaderboardSingletonService> 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<OperationResult<List<RwfcLeaderboardEntry>>> GetTopPlayersAsync(int limit = 50)
{
Expand All @@ -44,6 +49,17 @@ public async Task<OperationResult<List<RwfcLeaderboardEntry>>> 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<OperationResult<PlayerProfileResponse>> 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<List<RwfcLeaderboardEntry>> result)
{
if (
Expand Down
47 changes: 47 additions & 0 deletions WheelWizard/Features/WheelWizardData/Views/FriendsPage.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -98,6 +100,7 @@ public FriendsPage(
IModalFactory popups,
INavigationService navigation,
LiveRoomsService liveRooms,
IRrLeaderboardSingletonService leaderboard,
IGameLicenseSingletonService gameLicenseService,
IMiiDbService miiDbService,
ISettingsManager settingsService
Expand All @@ -106,6 +109,7 @@ ISettingsManager settingsService
Popups = popups;
Navigation = navigation;
LiveRooms = liveRooms;
Leaderboard = leaderboard;
GameLicenseService = gameLicenseService;
MiiDbService = miiDbService;
SettingsService = settingsService;
Expand Down Expand Up @@ -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));
}

/// <summary>
/// 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.
/// </summary>
private Task LoadRatingsAsync(List<FriendProfile> friends)
{
var lookups = new List<Task>();
foreach (var friend in friends)
{
if (LiveVr(friend) is { } liveVr)
friend.Vr = liveVr;
else
lookups.Add(LoadVrAsync(friend));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,21 @@ ILogger<GameLicenseSingletonService> 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;

/// <summary>
/// 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.
/// </summary>
public static uint FriendRatingFromRksys(uint stored) =>
stored == DefaultRating || stored > MaxRating / FriendRatingScale ? stored : stored * FriendRatingScale;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

/// <summary>Turns rating points into the friend VR/BR stored in rksys.dat.</summary>
public static ushort FriendRatingToRksys(uint points) => (ushort)(Math.Min(points, MaxRating) / FriendRatingScale);

/// <summary>
/// Returns the "focused" or currently active license/user as determined by the Settings.
/// </summary>
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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();
Expand Down
Loading