diff --git a/WheelWizard.Test/Features/Ghosts/GhostTrackServiceTests.cs b/WheelWizard.Test/Features/Ghosts/GhostTrackServiceTests.cs new file mode 100644 index 00000000..07f3512d --- /dev/null +++ b/WheelWizard.Test/Features/Ghosts/GhostTrackServiceTests.cs @@ -0,0 +1,111 @@ +using System.Net; +using System.Net.Http; +using System.Text; +using System.Collections.Concurrent; +using WheelWizard.Services; + +namespace WheelWizard.Test.Features.Ghosts; + +public class GhostTrackServiceTests +{ + [Fact] + public async Task GetAllTracksAsync_LoadsTracksWithoutEagerHashResolution_AndCachesResults() + { + var handler = new StubHttpMessageHandler(new Dictionary + { + ["https://rwfc.net/api/timetrial/tracks"] = + """ + [ + { "id": 1, "name": "Main Track", "courseId": 300, "category": "retro", "laps": 3, "supportsGlitch": true, "sortOrder": 1 }, + { "id": 2, "name": "Variant Track", "courseId": 300, "category": "retro", "laps": 3, "supportsGlitch": true, "sortOrder": 2 } + ] + """, + ["https://rwfc.net/api/timetrial/worldrecords/all?glitchAllowed=true&cc=150"] = + """ + [ + { "trackId": 1, "trackName": "Main Track", "activeWorldRecord": { "id": 100, "trackId": 1, "trackName": "Main Track", "playerName": "P1", "cc": 150, "finishTimeMs": 100000, "finishTimeDisplay": "1:40.000", "miiName": "Mii", "dateSet": "2026-01-01", "submittedAt": "2026-01-01T00:00:00Z", "rank": 1 } }, + { "trackId": 2, "trackName": "Variant Track", "activeWorldRecord": { "id": 101, "trackId": 2, "trackName": "Variant Track", "playerName": "P2", "cc": 150, "finishTimeMs": 101000, "finishTimeDisplay": "1:41.000", "miiName": "Mii", "dateSet": "2026-01-01", "submittedAt": "2026-01-01T00:00:00Z", "rank": 1 } } + ] + """ + }); + + using var client = new HttpClient(handler); + var hexMapping = new TrackHexMappingService(); + var variantMapping = new TrackVariantMappingService(); + var service = new GhostTrackService(client, hexMapping, variantMapping); + + var firstLoad = await service.GetAllTracksAsync(); + var secondLoad = await service.GetAllTracksAsync(); + + Assert.Equal(2, firstLoad.Count); + Assert.All(firstLoad, t => Assert.Equal(string.Empty, t.HexValue)); + Assert.Equal(firstLoad.Count, secondLoad.Count); + + Assert.Equal(1, handler.GetRequestCount("https://rwfc.net/api/timetrial/tracks")); + Assert.Equal(1, handler.GetRequestCount("https://rwfc.net/api/timetrial/worldrecords/all?glitchAllowed=true&cc=150")); + } + + [Fact] + public async Task EnsureTrackMappingsInitializedAsync_InitializesVariantMappingAfterTrackLoad() + { + var handler = new StubHttpMessageHandler(new Dictionary + { + ["https://rwfc.net/api/timetrial/tracks"] = + """ + [ + { "id": 10, "name": "Main Track", "courseId": 410, "category": "retro", "laps": 3, "supportsGlitch": true, "sortOrder": 1 }, + { "id": 11, "name": "Variant Track", "courseId": 410, "category": "retro", "laps": 3, "supportsGlitch": true, "sortOrder": 2 } + ] + """, + ["https://rwfc.net/api/timetrial/worldrecords/all?glitchAllowed=true&cc=150"] = "[]" + }); + + using var client = new HttpClient(handler); + var hexMapping = new TrackHexMappingService(); + var variantMapping = new TrackVariantMappingService(); + var service = new GhostTrackService(client, hexMapping, variantMapping); + + await service.GetAllTracksAsync(); + Assert.False(variantMapping.IsVariantTrack("Variant Track")); + + await service.EnsureTrackMappingsInitializedAsync(); + + Assert.True(variantMapping.IsVariantTrack("Variant Track")); + Assert.Equal("Main Track", variantMapping.GetMainTrackName("Variant Track")); + } + + private sealed class StubHttpMessageHandler : HttpMessageHandler + { + private readonly Dictionary _responses; + private readonly ConcurrentDictionary _requestCounts = new(StringComparer.OrdinalIgnoreCase); + + public StubHttpMessageHandler(Dictionary responses) + { + _responses = responses; + } + + public int GetRequestCount(string url) + { + return _requestCounts.TryGetValue(url, out var count) ? count : 0; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var url = request.RequestUri!.ToString(); + _requestCounts.AddOrUpdate(url, 1, (_, current) => current + 1); + + if (!_responses.TryGetValue(url, out var content)) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent(string.Empty) + }); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(content, Encoding.UTF8, "application/json") + }); + } + } +} diff --git a/WheelWizard.Test/Features/Ghosts/TrackVariantMappingServiceTests.cs b/WheelWizard.Test/Features/Ghosts/TrackVariantMappingServiceTests.cs new file mode 100644 index 00000000..62ae4416 --- /dev/null +++ b/WheelWizard.Test/Features/Ghosts/TrackVariantMappingServiceTests.cs @@ -0,0 +1,50 @@ +using WheelWizard.Models; +using WheelWizard.Services; + +namespace WheelWizard.Test.Features.Ghosts; + +public class TrackVariantMappingServiceTests +{ + [Fact] + public void InitializeFromTrackInfo_BuildsVariantAndMainMappingsByCourseId() + { + var service = new TrackVariantMappingService(); + + var tracks = new[] + { + new TrackInfo { Id = 10, Name = "Main Track", CourseId = 300 }, + new TrackInfo { Id = 11, Name = "Variant Track", CourseId = 300 }, + new TrackInfo { Id = 20, Name = "Solo Track", CourseId = 301 } + }; + + service.InitializeFromTrackInfo(tracks); + + Assert.True(service.IsVariantTrack("Variant Track")); + Assert.False(service.IsVariantTrack("Main Track")); + Assert.Equal("Main Track", service.GetMainTrackName("Variant Track")); + Assert.Equal("Solo Track", service.GetMainTrackName("Solo Track")); + + var variants = service.GetVariantsForMainTrack("Main Track"); + Assert.Single(variants); + Assert.Contains("Variant Track", variants); + Assert.Empty(service.GetVariantsForMainTrack("Solo Track")); + } + + [Fact] + public void AddAndRemoveVariantMapping_UpdatesVariantState() + { + var service = new TrackVariantMappingService(); + + service.AddVariantMapping("Main A", "Variant A1"); + + Assert.True(service.IsVariantTrack("Variant A1")); + Assert.Equal("Main A", service.GetMainTrackName("Variant A1")); + Assert.Contains("Variant A1", service.GetVariantsForMainTrack("Main A")); + + service.RemoveVariantMapping("Variant A1"); + + Assert.False(service.IsVariantTrack("Variant A1")); + Assert.Equal("Variant A1", service.GetMainTrackName("Variant A1")); + Assert.Empty(service.GetVariantsForMainTrack("Main A")); + } +} diff --git a/WheelWizard/Converters/GhostDisplayConverters.cs b/WheelWizard/Converters/GhostDisplayConverters.cs new file mode 100644 index 00000000..cdf6d697 --- /dev/null +++ b/WheelWizard/Converters/GhostDisplayConverters.cs @@ -0,0 +1,259 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Avalonia.Data.Converters; + +namespace WheelWizard.Converters; + +public class RankToMedalConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is int rank) + { + return rank switch + { + 1 => "🥇", + 2 => "🥈", + 3 => "🥉", + _ => $"#{rank}" + }; + } + return "#?"; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +public class CountryCodeToFlagConverter : IValueConverter +{ + private static readonly Dictionary CountryFlags = new() + { + { "US", "🇺🇸" }, { "GB", "🇬🇧" }, { "CA", "🇨🇦" }, { "FR", "🇫🇷" }, { "DE", "🇩🇪" }, + { "IT", "🇮🇹" }, { "ES", "🇪🇸" }, { "JP", "🇯🇵" }, { "KR", "🇰🇷" }, { "CN", "🇨🇳" }, + { "AU", "🇦🇺" }, { "BR", "🇧🇷" }, { "MX", "🇲🇽" }, { "NL", "🇳🇱" }, { "SE", "🇸🇪" }, + { "NO", "🇳🇴" }, { "DK", "🇩🇰" }, { "FI", "🇫🇮" }, { "CH", "🇨🇭" }, { "AT", "🇦🇹" }, + { "BE", "🇧🇪" }, { "PT", "🇵🇹" }, { "PL", "🇵🇱" }, { "RU", "🇷🇺" }, { "CZ", "🇨🇿" }, + { "HU", "🇭🇺" }, { "GR", "🇬🇷" }, { "TR", "🇹🇷" }, { "IL", "🇮🇱" }, { "IN", "🇮🇳" }, + { "TH", "🇹🇭" }, { "SG", "🇸🇬" }, { "MY", "🇲🇾" }, { "PH", "🇵🇭" }, { "ID", "🇮🇩" }, + { "VN", "🇻🇳" }, { "TW", "🇹🇼" }, { "HK", "🇭🇰" }, { "NZ", "🇳🇿" }, { "ZA", "🇿🇦" }, + { "EG", "🇪🇬" }, { "NG", "🇳🇬" }, { "KE", "🇰🇪" }, { "MA", "🇲🇦" }, { "AR", "🇦🇷" }, + { "CL", "🇨🇱" }, { "CO", "🇨🇴" }, { "PE", "🇵🇪" }, { "VE", "🇻🇪" }, { "UY", "🇺🇾" }, + { "EC", "🇪🇨" }, { "BO", "🇧🇴" }, { "PY", "🇵🇾" }, { "CR", "🇨🇷" }, { "PA", "🇵🇦" }, + { "GT", "🇬🇹" }, { "HN", "🇭🇳" }, { "SV", "🇸🇻" }, { "NI", "🇳🇮" }, { "BZ", "🇧🇿" }, + { "JM", "🇯🇲" }, { "TT", "🇹🇹" }, { "BB", "🇧🇧" }, { "GY", "🇬🇾" }, { "SR", "🇸🇷" }, + { "UE", "🇪🇺" }, { "IE", "🇮🇪" }, { "IS", "🇮🇸" }, { "LU", "🇱🇺" }, { "MC", "🇲🇨" }, + { "AD", "🇦🇩" }, { "SM", "🇸🇲" }, { "VA", "🇻🇦" }, { "MT", "🇲🇹" }, { "CY", "🇨🇾" } + }; + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is string countryCode && !string.IsNullOrEmpty(countryCode)) + { + return CountryFlags.TryGetValue(countryCode.ToUpperInvariant(), out var flag) ? flag : countryCode; + } + return "🌍"; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +public class CharacterIdToNameConverter : IValueConverter +{ + private static readonly Dictionary CharacterNames = new() + { + // https://wiki.tockdom.com/wiki/List_of_Identifiers + { 0x00, "Mario" }, + { 0x01, "Baby Peach" }, + { 0x02, "Waluigi" }, + { 0x03, "Bowser" }, + { 0x04, "Baby Daisy" }, + { 0x05, "Dry Bones" }, + { 0x06, "Baby Mario" }, + { 0x07, "Luigi" }, + { 0x08, "Toad" }, + { 0x09, "Donkey Kong" }, + { 0x0A, "Yoshi" }, + { 0x0B, "Wario" }, + { 0x0C, "Baby Luigi" }, + { 0x0D, "Toadette" }, + { 0x0E, "Koopa Troopa" }, + { 0x0F, "Daisy" }, + { 0x10, "Peach" }, + { 0x11, "Birdo" }, + { 0x12, "Diddy Kong" }, + { 0x13, "King Boo" }, + { 0x14, "Bowser Jr." }, + { 0x15, "Dry Bowser" }, + { 0x16, "Funky Kong" }, + { 0x17, "Rosalina" }, + { 0x18, "Small Mii A (Male)" }, + { 0x19, "Small Mii A (Female)" }, + { 0x1A, "Small Mii B (Male)" }, + { 0x1B, "Small Mii B (Female)" }, + { 0x1C, "Small Mii C (Male)" }, + { 0x1D, "Small Mii C (Female)" }, + { 0x1E, "Medium Mii A (Male)" }, + { 0x1F, "Medium Mii A (Female)" }, + { 0x20, "Medium Mii B (Male)" }, + { 0x21, "Medium Mii B (Female)" }, + { 0x22, "Medium Mii C (Male)" }, + { 0x23, "Medium Mii C (Female)" }, + { 0x24, "Large Mii A (Male)" }, + { 0x25, "Large Mii A (Female)" }, + { 0x26, "Large Mii B (Male)" }, + { 0x27, "Large Mii B (Female)" }, + { 0x28, "Large Mii C (Male)" }, + { 0x29, "Large Mii C (Female)" }, + { 0x2A, "Medium Mii" }, + { 0x2B, "Small Mii" }, + { 0x2C, "Large Mii" }, + { 0x2D, "Peach" }, + { 0x2E, "Daisy" }, + { 0x2F, "Rosalina" } + }; + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is int characterId) + { + return CharacterNames.TryGetValue(characterId, out var name) ? name : $"Character {characterId:X2}"; + } + return "Unknown"; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +public class VehicleIdToNameConverter : IValueConverter +{ + private static readonly Dictionary VehicleNames = new() + { + // https://wiki.tockdom.com/wiki/List_of_Identifiers + { 0x00, "Standard Kart S" }, + { 0x01, "Standard Kart M" }, + { 0x02, "Standard Kart L" }, + { 0x03, "Booster Seat" }, + { 0x04, "Classic Dragster" }, + { 0x05, "Offroader" }, + { 0x06, "Mini Beast" }, + { 0x07, "Wild Wing" }, + { 0x08, "Flame Flyer" }, + { 0x09, "Cheep Charger" }, + { 0x0A, "Super Blooper" }, + { 0x0B, "Piranha Prowler" }, + { 0x0C, "Tiny Titan" }, + { 0x0D, "Daytripper" }, + { 0x0E, "Jetsetter" }, + { 0x0F, "Blue Falcon" }, + { 0x10, "Sprinter" }, + { 0x11, "Honeycoupe" }, + { 0x12, "Standard Bike S" }, + { 0x13, "Standard Bike M" }, + { 0x14, "Standard Bike L" }, + { 0x15, "Bullet Bike" }, + { 0x16, "Mach Bike" }, + { 0x17, "Flame Runner" }, + { 0x18, "Bit Bike" }, + { 0x19, "Sugarscoot" }, + { 0x1A, "Wario Bike" }, + { 0x1B, "Quacker" }, + { 0x1C, "Zip Zip" }, + { 0x1D, "Shooting Star" }, + { 0x1E, "Magikruiser" }, + { 0x1F, "Sneakster" }, + { 0x20, "Spear" }, + { 0x21, "Jet Bubble" }, + { 0x22, "Dolphin Dasher" }, + { 0x23, "Phantom" } + }; + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is int vehicleId) + { + return VehicleNames.TryGetValue(vehicleId, out var name) ? name : $"Vehicle {vehicleId:X2}"; + } + return "Unknown"; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +public class DriftTypeToNameConverter : IValueConverter +{ + private static readonly Dictionary DriftTypes = new() + { + { 0, "Manual" }, { 1, "Automatic" } + }; + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is int driftType) + { + return DriftTypes.TryGetValue(driftType, out var name) ? name : $"Drift {driftType}"; + } + return "Unknown"; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +public class ControllerTypeToNameConverter : IValueConverter +{ + private static readonly Dictionary ControllerTypes = new() + { + { 0, "Wii Wheel" }, { 1, "Nunchuk" }, { 2, "Classic Controller" }, + { 3, "GameCube Controller" } + }; + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is int controllerType) + { + return ControllerTypes.TryGetValue(controllerType, out var name) ? name : $"Controller {controllerType}"; + } + return "Unknown"; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +public class LapTimesToAverageConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is List lapTimesMs && lapTimesMs.Count > 0) + { + var averageMs = lapTimesMs.Average(); + var minutes = (int)(averageMs / 60000); + var seconds = (averageMs % 60000) / 1000; + return $"{minutes}:{seconds:00.000}"; + } + return "0:00.000"; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/WheelWizard/Models/GhostLeaderboard.cs b/WheelWizard/Models/GhostLeaderboard.cs new file mode 100644 index 00000000..88c6fe82 --- /dev/null +++ b/WheelWizard/Models/GhostLeaderboard.cs @@ -0,0 +1,171 @@ +using System.Text.Json.Serialization; + +namespace WheelWizard.Models; + +public class GhostLeaderboardResponse +{ + [JsonPropertyName("track")] + public GhostTrackInfo Track { get; set; } = new(); + + [JsonPropertyName("cc")] + public int Cc { get; set; } + + [JsonPropertyName("glitchAllowed")] + public bool GlitchAllowed { get; set; } + + [JsonPropertyName("shroomless")] + public bool? Shroomless { get; set; } + + [JsonPropertyName("vehicleFilter")] + public string? VehicleFilter { get; set; } + + [JsonPropertyName("isFlap")] + public bool IsFlap { get; set; } + + [JsonPropertyName("submissions")] + public List Submissions { get; set; } = new(); + + [JsonPropertyName("totalSubmissions")] + public int TotalSubmissions { get; set; } + + [JsonPropertyName("currentPage")] + public int CurrentPage { get; set; } + + [JsonPropertyName("pageSize")] + public int PageSize { get; set; } + + [JsonPropertyName("totalPages")] + public int TotalPages { get; set; } + + [JsonPropertyName("fastestLapMs")] + public int? FastestLapMs { get; set; } + + [JsonPropertyName("fastestLapDisplay")] + public string FastestLapDisplay { get; set; } = string.Empty; +} + +public class GhostTrackInfo +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("courseId")] + public int CourseId { get; set; } + + [JsonPropertyName("category")] + public string Category { get; set; } = string.Empty; + + [JsonPropertyName("laps")] + public int Laps { get; set; } + + [JsonPropertyName("supportsGlitch")] + public bool SupportsGlitch { get; set; } + + [JsonPropertyName("sortOrder")] + public int SortOrder { get; set; } +} + +public class GhostSubmission +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("trackId")] + public int TrackId { get; set; } + + [JsonPropertyName("trackName")] + public string TrackName { get; set; } = string.Empty; + + [JsonPropertyName("ttProfileId")] + public int TtProfileId { get; set; } + + [JsonPropertyName("playerName")] + public string PlayerName { get; set; } = string.Empty; + + [JsonPropertyName("countryCode")] + public int CountryCode { get; set; } + + [JsonPropertyName("countryAlpha2")] + public string? CountryAlpha2 { get; set; } + + [JsonPropertyName("countryName")] + public string? CountryName { get; set; } + + [JsonPropertyName("cc")] + public int Cc { get; set; } + + [JsonPropertyName("finishTimeMs")] + public int FinishTimeMs { get; set; } + + [JsonPropertyName("finishTimeDisplay")] + public string FinishTimeDisplay { get; set; } = string.Empty; + + [JsonPropertyName("vehicleId")] + public int VehicleId { get; set; } + + [JsonPropertyName("characterId")] + public int CharacterId { get; set; } + + [JsonPropertyName("controllerType")] + public int ControllerType { get; set; } + + [JsonPropertyName("driftType")] + public int DriftType { get; set; } + + [JsonPropertyName("shroomless")] + public bool Shroomless { get; set; } + + [JsonPropertyName("glitch")] + public bool Glitch { get; set; } + + [JsonPropertyName("isFlap")] + public bool IsFlap { get; set; } + + [JsonPropertyName("driftCategory")] + public int DriftCategory { get; set; } + + [JsonPropertyName("miiName")] + public string MiiName { get; set; } = string.Empty; + + [JsonPropertyName("lapCount")] + public int LapCount { get; set; } + + [JsonPropertyName("lapSplitsMs")] + public List LapSplitsMs { get; set; } = new(); + + [JsonPropertyName("lapSplitsDisplay")] + public List LapSplitsDisplay { get; set; } = new(); + + [JsonPropertyName("fastestLapMs")] + public int? FastestLapMs { get; set; } + + [JsonPropertyName("fastestLapDisplay")] + public string FastestLapDisplay { get; set; } = string.Empty; + + [JsonPropertyName("ghostFilePath")] + public string GhostFilePath { get; set; } = string.Empty; + + [JsonPropertyName("dateSet")] + public string DateSet { get; set; } = string.Empty; + + [JsonPropertyName("submittedAt")] + public DateTime SubmittedAt { get; set; } + + [JsonPropertyName("rank")] + public int Rank { get; set; } +} + +public enum GhostCc +{ + Cc150 = 150, + Cc200 = 200 +} + +public enum GhostLeaderboardType +{ + Regular, + Flap +} \ No newline at end of file diff --git a/WheelWizard/Models/GhostTrack.cs b/WheelWizard/Models/GhostTrack.cs new file mode 100644 index 00000000..5427a30f --- /dev/null +++ b/WheelWizard/Models/GhostTrack.cs @@ -0,0 +1,35 @@ +namespace WheelWizard.Models; + +public class GhostTrack +{ + public int TrackId { get; set; } // Added API track ID for leaderboard calls + public string Name { get; set; } = string.Empty; + public string HexValue { get; set; } = string.Empty; + public string Console { get; set; } = string.Empty; + public GhostTrackType TrackType { get; set; } + + public bool IsCustomTrack => TrackType == GhostTrackType.Custom; + public bool IsRetro => TrackType == GhostTrackType.Retro; + public string DisplayName => IsCustomTrack ? Name : $"{Console} {Name}"; +} + +public enum GhostTrackType +{ + All, + Retro, + Custom +} + +public enum GhostLocation +{ + All, + Online, + Local +} + +public enum GhostLocationFilter +{ + All, + Online, + Local +} \ No newline at end of file diff --git a/WheelWizard/Models/LocalGhostData.cs b/WheelWizard/Models/LocalGhostData.cs new file mode 100644 index 00000000..ec0ba822 --- /dev/null +++ b/WheelWizard/Models/LocalGhostData.cs @@ -0,0 +1,84 @@ +using System.Linq; + +namespace WheelWizard.Models; + +/// +/// Represents a local ghost record parsed from an RKG file +/// +public class LocalGhostData +{ + public string FilePath { get; set; } = string.Empty; + public string FileName => Path.GetFileNameWithoutExtension(FilePath); + + // Track information + public uint TrackId { get; set; } + public string TrackName { get; set; } = string.Empty; + + // Character/Vehicle setup + public byte CharacterId { get; set; } + public string CharacterName { get; set; } = string.Empty; + public byte VehicleId { get; set; } + public string VehicleName { get; set; } = string.Empty; + public byte ControllerType { get; set; } + public string ControllerName { get; set; } = string.Empty; + + // Timing data + public uint TotalTimeMs { get; set; } + public string TotalTimeDisplay => FormatTime(TotalTimeMs); + public List LapSplitsMs { get; set; } = new(); + public List LapSplitsDisplay => LapSplitsMs.Select(FormatTime).ToList(); + public uint FastestLapMs => LapSplitsMs.Count > 0 ? LapSplitsMs.Min() : 0; + public string FastestLapDisplay => FormatTime(FastestLapMs); + public double AverageLapMs => LapSplitsMs.Count > 0 ? LapSplitsMs.Select(x => (double)x).Average() : 0; + public string AverageLapDisplay => FormatTime((uint)AverageLapMs); + + // Metadata + public DateTime Date { get; set; } + public string DateDisplay => Date.ToString("MM/dd/yyyy"); + public ushort Country { get; set; } + public string CountryName { get; set; } = string.Empty; + public string MiiData { get; set; } = string.Empty; + + // CC information (derived from folder structure) + public int Cc { get; set; } = 150; // 150 or 200 + public bool IsVariant { get; set; } // true if in variant subfolder + + // Ranking information (set when loading ghosts) + public int Rank { get; set; } = 0; + + private static string FormatTime(uint timeMs) + { + if (timeMs == 0) return "0:00.000"; + + var totalSeconds = timeMs / 1000.0; + var minutes = (int)(totalSeconds / 60); + var seconds = totalSeconds % 60; + + return $"{minutes}:{seconds:00.000}"; + } +} + +/// +/// Represents a group of local ghost records for a specific track +/// +public class LocalTrackGhosts +{ + public uint TrackId { get; set; } + public string TrackName { get; set; } = string.Empty; + public string HexFolderName { get; set; } = string.Empty; + public List Ghosts150 { get; set; } = new(); + public List Ghosts200 { get; set; } = new(); + public List VariantGhosts150 { get; set; } = new(); // For variant tracks + public List VariantGhosts200 { get; set; } = new(); + + public int TotalGhostCount => Ghosts150.Count + Ghosts200.Count + VariantGhosts150.Count + VariantGhosts200.Count; + public bool HasGhosts => TotalGhostCount > 0; + + public LocalGhostData? BestTime150 => Ghosts150.Concat(VariantGhosts150) + .OrderBy(g => g.TotalTimeMs) + .FirstOrDefault(); + + public LocalGhostData? BestTime200 => Ghosts200.Concat(VariantGhosts200) + .OrderBy(g => g.TotalTimeMs) + .FirstOrDefault(); +} \ No newline at end of file diff --git a/WheelWizard/Models/WorldRecordsResponse.cs b/WheelWizard/Models/WorldRecordsResponse.cs new file mode 100644 index 00000000..2aa215cc --- /dev/null +++ b/WheelWizard/Models/WorldRecordsResponse.cs @@ -0,0 +1,86 @@ +using System.Text.Json.Serialization; + +namespace WheelWizard.Models; + +public class WorldRecordsResponse +{ + [JsonPropertyName("trackId")] + public int TrackId { get; set; } + + [JsonPropertyName("trackName")] + public string TrackName { get; set; } = string.Empty; + + [JsonPropertyName("activeWorldRecord")] + public GhostSubmission ActiveWorldRecord { get; set; } = new(); +} + +public class TrackInfo +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("courseId")] + public int CourseId { get; set; } + + [JsonPropertyName("category")] + public string Category { get; set; } = string.Empty; + + [JsonPropertyName("laps")] + public int Laps { get; set; } + + [JsonPropertyName("supportsGlitch")] + public bool SupportsGlitch { get; set; } + + [JsonPropertyName("sortOrder")] + public int SortOrder { get; set; } +} + +public class ApiTrack +{ + public int TrackId { get; set; } + public string Name { get; set; } = string.Empty; + public string Console { get; set; } = string.Empty; + public string HexValue { get; set; } = string.Empty; + public GhostTrackType TrackType { get; set; } + public GhostSubmission? WorldRecord { get; set; } + + public bool IsCustomTrack => TrackType == GhostTrackType.Custom; + public bool IsRetroTrack => TrackType == GhostTrackType.Retro; + + public static ApiTrack FromWorldRecordAndTrackInfo(WorldRecordsResponse wr, TrackInfo? trackInfo) + { + var track = new ApiTrack + { + TrackId = wr.TrackId, + Name = wr.TrackName, + WorldRecord = wr.ActiveWorldRecord + }; + + if (trackInfo != null) + { + track.TrackType = trackInfo.Category.ToLowerInvariant() switch + { + "retro" => GhostTrackType.Retro, + "custom" => GhostTrackType.Custom, + _ => GhostTrackType.All + }; + + track.Console = track.TrackType switch + { + GhostTrackType.Retro => "Retro", + GhostTrackType.Custom => "Custom", + _ => "Standard" + }; + } + else + { + track.TrackType = GhostTrackType.All; + track.Console = "Unknown"; + } + + return track; + } +} \ No newline at end of file diff --git a/WheelWizard/Resources/Languages/Common.Designer.cs b/WheelWizard/Resources/Languages/Common.Designer.cs index b11771e9..7577fc0d 100644 --- a/WheelWizard/Resources/Languages/Common.Designer.cs +++ b/WheelWizard/Resources/Languages/Common.Designer.cs @@ -95,6 +95,15 @@ public static string Action_Cancel { } } + /// + /// Looks up a localized string similar to Close. + /// + public static string Action_Close { + get { + return ResourceManager.GetString("Action_Close", resourceCulture); + } + } + /// /// Looks up a localized string similar to Copy Friend Code. /// @@ -473,6 +482,33 @@ public static string Attribute_BrFull { } } + /// + /// Looks up a localized string similar to Character:. + /// + public static string Attribute_Character { + get { + return ResourceManager.GetString("Attribute_Character", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Controller:. + /// + public static string Attribute_Controller { + get { + return ResourceManager.GetString("Attribute_Controller", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Date Set:. + /// + public static string Attribute_DateSet { + get { + return ResourceManager.GetString("Attribute_DateSet", resourceCulture); + } + } + /// /// Looks up a localized string similar to Description. /// @@ -482,6 +518,15 @@ public static string Attribute_Description { } } + /// + /// Looks up a localized string similar to Drift:. + /// + public static string Attribute_Drift { + get { + return ResourceManager.GetString("Attribute_Drift", resourceCulture); + } + } + /// /// Looks up a localized string similar to Downloads. /// @@ -1085,6 +1130,15 @@ public static string Attribute_Type { } } + /// + /// Looks up a localized string similar to Vehicle:. + /// + public static string Attribute_Vehicle { + get { + return ResourceManager.GetString("Attribute_Vehicle", resourceCulture); + } + } + /// /// Looks up a localized string similar to Views. /// @@ -1283,6 +1337,33 @@ public static string PopupTitle_ModDetails { } } + /// + /// Looks up a localized string similar to Ghost Run Details. + /// + public static string PopupTitle_GhostRunDetails { + get { + return ResourceManager.GetString("PopupTitle_GhostRunDetails", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Lap Times. + /// + public static string Section_LapTimes { + get { + return ResourceManager.GetString("Section_LapTimes", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Setup Details. + /// + public static string Section_SetupDetails { + get { + return ResourceManager.GetString("Section_SetupDetails", resourceCulture); + } + } + /// /// Looks up a localized string similar to America. /// diff --git a/WheelWizard/Resources/Languages/Common.resx b/WheelWizard/Resources/Languages/Common.resx index 4a0c130c..70f8ddab 100644 --- a/WheelWizard/Resources/Languages/Common.resx +++ b/WheelWizard/Resources/Languages/Common.resx @@ -497,4 +497,31 @@ Submit + + Close + + + Ghost Run Details + + + Lap Times + + + Setup Details + + + Character: + + + Vehicle: + + + Drift: + + + Controller: + + + Date Set: + \ No newline at end of file diff --git a/WheelWizard/Services/GhostLeaderboardService.cs b/WheelWizard/Services/GhostLeaderboardService.cs new file mode 100644 index 00000000..9229ca6b --- /dev/null +++ b/WheelWizard/Services/GhostLeaderboardService.cs @@ -0,0 +1,53 @@ +using System.Text.Json; +using WheelWizard.Models; +using Serilog; + +namespace WheelWizard.Services; + +public class GhostLeaderboardService +{ + private readonly HttpClient _httpClient; + private readonly string _baseUrl = "https://rwfc.net/api/timetrial/leaderboard"; + + public GhostLeaderboardService() + { + _httpClient = new HttpClient(); + _httpClient.DefaultRequestHeaders.Add("User-Agent", "WheelWizard/1.0"); + } + + public async Task GetLeaderboardAsync( + int trackId, + bool glitchAllowed, + GhostCc cc, + GhostLeaderboardType leaderboardType, + int page = 1, + int pageSize = 10) + { + try + { + var endpoint = leaderboardType == GhostLeaderboardType.Flap ? $"{_baseUrl}/flap" : _baseUrl; + var url = $"{endpoint}?glitchAllowed={glitchAllowed.ToString().ToLower()}&trackId={trackId}&cc={((int)cc)}&page={page}&pageSize={pageSize}"; + + var response = await _httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + return JsonSerializer.Deserialize(json, options); + } + catch (Exception ex) + { + Log.Error(ex, $"Failed to load track info from API: {ex.Message}"); + return null; + } + } + + public void Dispose() + { + _httpClient?.Dispose(); + } +} \ No newline at end of file diff --git a/WheelWizard/Services/GhostSaveHelper.cs b/WheelWizard/Services/GhostSaveHelper.cs new file mode 100644 index 00000000..3e751ec5 --- /dev/null +++ b/WheelWizard/Services/GhostSaveHelper.cs @@ -0,0 +1,46 @@ +using WheelWizard.Models; +using WheelWizard.Services; +using Serilog; + +namespace WheelWizard.Services; + +/// +/// Helper service demonstrating usage of the track variant system +/// +public class GhostSaveHelper +{ + private readonly LocalGhostService _localGhostService; + private readonly TrackVariantMappingService _variantMappingService; + private readonly TrackHexMappingService _trackHexMappingService; + + public GhostSaveHelper( + LocalGhostService localGhostService, + TrackVariantMappingService variantMappingService, + TrackHexMappingService trackHexMappingService) + { + _localGhostService = localGhostService; + _variantMappingService = variantMappingService; + _trackHexMappingService = trackHexMappingService; + } + + /// + /// Gets the correct ghost folder path for saving/loading ghosts for a specific track + /// + /// Full track name (e.g., "GP Bowser's Castle" or "GP Castle Wall") + /// CC value (150 or 200) + /// Full path to the ghost folder, or null if track not found + public string? GetGhostFolderPath(string trackName, int cc) + { + return _variantMappingService.GetGhostFolderPath(trackName, cc, _trackHexMappingService, PathManager.GhostsFolderPath); + } + + /// + /// Opens the correct ghost folder in file explorer for the specified track + /// + /// Full track name + /// Optional CC to open specific CC folder + public void OpenGhostFolder(string trackName, int? cc = null) + { + _localGhostService.OpenTrackFolderInExplorer(trackName, cc); + } +} \ No newline at end of file diff --git a/WheelWizard/Services/GhostTrackService.cs b/WheelWizard/Services/GhostTrackService.cs new file mode 100644 index 00000000..d6c73a5f --- /dev/null +++ b/WheelWizard/Services/GhostTrackService.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using WheelWizard.Models; +using Serilog; + +namespace WheelWizard.Services +{ + public class GhostTrackService + { + private readonly HttpClient _httpClient; + private readonly TrackHexMappingService _hexMappingService; + private readonly TrackVariantMappingService _variantMappingService; + private List _allTracks = new(); + private Dictionary _trackInfoCache = new(); + private bool _tracksLoaded = false; + private bool _trackInfoLoaded = false; + private bool _trackMappingsInitialized = false; + + public GhostTrackService( + HttpClient httpClient, + TrackHexMappingService hexMappingService, + TrackVariantMappingService variantMappingService) + { + _httpClient = httpClient; + _hexMappingService = hexMappingService; + _variantMappingService = variantMappingService; + } + + public async Task> GetAllTracksAsync() + { + if (_tracksLoaded && _allTracks.Count > 0) + { + Log.Debug("Returning cached track data ({Count} tracks)", _allTracks.Count); + return _allTracks; + } + + try + { + Log.Information("Loading tracks from API (first time)"); + + var trackInfoTask = LoadTrackInfoAsync(initializeMappings: false); + const string worldRecordsUrl = "https://rwfc.net/api/timetrial/worldrecords/all?glitchAllowed=true&cc=150"; + var worldRecordsTask = _httpClient.GetStringAsync(worldRecordsUrl); + + await Task.WhenAll(trackInfoTask, worldRecordsTask).ConfigureAwait(false); + + var response = await worldRecordsTask.ConfigureAwait(false); + + var jsonOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + var worldRecords = await Task.Run(() => + JsonSerializer.Deserialize(response, jsonOptions) + ).ConfigureAwait(false); + + if (worldRecords != null) + { + _allTracks = await Task.Run(() => + worldRecords + .Where(wr => wr.TrackId > 0) + .Select(wr => + { + _trackInfoCache.TryGetValue(wr.TrackId, out var trackInfo); + var track = ApiTrack.FromWorldRecordAndTrackInfo(wr, trackInfo); + track.HexValue = string.Empty; + + return track; + }) + .OrderBy(t => t.TrackType) + .ThenBy(t => t.Name) + .ToList() + ).ConfigureAwait(false); + + _tracksLoaded = true; + Log.Information("Loaded and cached {Count} tracks from API with category data", _allTracks.Count); + } + } + catch (Exception ex) + { + Log.Error(ex, "Failed to load tracks from API"); + } + + return _allTracks; + } + + private async Task LoadTrackInfoAsync(bool initializeMappings = true) + { + if (_trackInfoLoaded && _trackInfoCache.Count > 0) + { + Log.Debug("Using cached track info ({Count} tracks)", _trackInfoCache.Count); + if (initializeMappings && !_trackMappingsInitialized) + { + var tracks = _trackInfoCache.Values.ToArray(); + _hexMappingService.InitializeFromTrackInfo(tracks); + _variantMappingService.InitializeFromTrackInfo(tracks); + _trackMappingsInitialized = true; + } + return; + } + + try + { + Log.Information("Loading track info from tracks API (first time)"); + const string tracksUrl = "https://rwfc.net/api/timetrial/tracks"; + var response = await _httpClient.GetStringAsync(tracksUrl).ConfigureAwait(false); + + var jsonOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + var tracks = await Task.Run(() => + JsonSerializer.Deserialize(response, jsonOptions) + ).ConfigureAwait(false); + + if (tracks != null) + { + _trackInfoCache = tracks.ToDictionary(t => t.Id); + if (initializeMappings) + { + _hexMappingService.InitializeFromTrackInfo(tracks); + _variantMappingService.InitializeFromTrackInfo(tracks); + _trackMappingsInitialized = true; + } + _trackInfoLoaded = true; + Log.Information("Loaded and cached track info for {Count} tracks", _trackInfoCache.Count); + } + } + catch (Exception ex) + { + Log.Error(ex, "Failed to load track info from API"); + } + } + + public async Task EnsureTrackMappingsInitializedAsync() + { + await LoadTrackInfoAsync(initializeMappings: true).ConfigureAwait(false); + } + + public List GetFilteredTracks(GhostTrackType trackFilter, GhostLocation locationFilter, string searchText = "") + { + var filtered = _allTracks.AsEnumerable(); + + filtered = trackFilter switch + { + GhostTrackType.Retro => filtered.Where(t => t.TrackType == GhostTrackType.Retro), + GhostTrackType.Custom => filtered.Where(t => t.TrackType == GhostTrackType.Custom), + _ => filtered + }; + + if (!string.IsNullOrWhiteSpace(searchText)) + { + filtered = filtered.Where(t => + t.Name.Contains(searchText, StringComparison.OrdinalIgnoreCase) || + t.Console.Contains(searchText, StringComparison.OrdinalIgnoreCase)); + } + + return filtered.ToList(); + } + } +} \ No newline at end of file diff --git a/WheelWizard/Services/LocalGhostService.cs b/WheelWizard/Services/LocalGhostService.cs new file mode 100644 index 00000000..63d52d82 --- /dev/null +++ b/WheelWizard/Services/LocalGhostService.cs @@ -0,0 +1,295 @@ +using WheelWizard.Models; +using WheelWizard.Services; +using Serilog; + +namespace WheelWizard.Services; + +/// +/// Service for discovering and parsing local ghost files from the Dolphin/Wii file system +/// +public class LocalGhostService +{ + private readonly Dictionary _cachedTrackGhosts = new(); + private readonly TrackVariantMappingService _variantMappingService; + private readonly TrackHexMappingService _trackHexMappingService; + private bool _hasScannedFolder = false; + + public LocalGhostService(TrackVariantMappingService variantMappingService, TrackHexMappingService trackHexMappingService) + { + _variantMappingService = variantMappingService; + _trackHexMappingService = trackHexMappingService; + } + + /// + /// Scans the Dolphin ghosts folder and returns local ghost data for all tracks + /// + public async Task> GetAllLocalGhostsAsync() + { + if (_hasScannedFolder && _cachedTrackGhosts.Count > 0) + { + Log.Information("Using cached local ghost data for {TrackCount} tracks", _cachedTrackGhosts.Count); + return _cachedTrackGhosts; + } + + await Task.Run(() => ScanGhostsFolder()); + _hasScannedFolder = true; + + Log.Information("Loaded local ghost data for {TrackCount} tracks", _cachedTrackGhosts.Count); + return _cachedTrackGhosts; + } + + /// + /// Gets local ghost data for a specific track by its ID + /// + public async Task GetTrackGhostsAsync(uint trackId, string hexValue) + { + var allGhosts = await GetAllLocalGhostsAsync(); + + if (allGhosts.TryGetValue(trackId, out var trackGhosts)) + { + return trackGhosts; + } + + return ScanTrackFolder(trackId, hexValue); + } + + /// + /// Opens the correct ghost folder for a specific track (considering variant mappings) in file explorer + /// + /// The full track name + /// Optional CC value to open specific CC folder (150 or 200) + public void OpenTrackFolderInExplorer(string trackName, int? cc = null) + { + try + { + Log.Information("Opening ghost folder for track: {TrackName}, CC: {CC}", trackName, cc); + + var isVariant = _variantMappingService.IsVariantTrack(trackName); + var mainTrack = _variantMappingService.GetMainTrackName(trackName); + Log.Information("Track variant status - IsVariant: {IsVariant}, MainTrack: {MainTrack}", isVariant, mainTrack); + + var hexValue = _variantMappingService.GetHexValueForTrack(trackName, _trackHexMappingService); + if (hexValue == null) + { + Log.Warning("No hex value found for track: {TrackName}", trackName); + return; + } + + Log.Information("Using hex value: {HexValue}", hexValue); + + string targetFolderPath; + + if (cc.HasValue) + { + // Open specific CC folder with variant support + var ccFolderPath = _variantMappingService.GetGhostFolderPath(trackName, cc.Value, + _trackHexMappingService, PathManager.GhostsFolderPath); + + if (ccFolderPath == null) + { + Log.Warning("Could not determine ghost folder path for track: {TrackName}", trackName); + return; + } + + targetFolderPath = ccFolderPath; + Log.Information("Using CC-specific folder path: {TargetPath}", targetFolderPath); + } + else + { + // Start with the main hex folder + targetFolderPath = Path.Combine(PathManager.GhostsFolderPath, hexValue.ToLowerInvariant()); + + // If it's a variant track, navigate to the variant subfolder + if (_variantMappingService.IsVariantTrack(trackName)) + { + targetFolderPath = Path.Combine(targetFolderPath, "1"); + Log.Information("Variant track - opening variant subfolder: {TargetPath}", targetFolderPath); + } + else + { + Log.Information("Main track - opening hex folder: {TargetPath}", targetFolderPath); + } + } + + if (!Directory.Exists(targetFolderPath)) + { + Log.Information("Creating ghost folder: {FolderPath}", targetFolderPath); + Directory.CreateDirectory(targetFolderPath); + + if (!cc.HasValue) + { + Directory.CreateDirectory(Path.Combine(targetFolderPath, "150")); + Directory.CreateDirectory(Path.Combine(targetFolderPath, "200")); + + if (_variantMappingService.IsVariantTrack(trackName)) + { + var variantFolder = Path.Combine(targetFolderPath, "1"); + Directory.CreateDirectory(variantFolder); + Directory.CreateDirectory(Path.Combine(variantFolder, "150")); + Directory.CreateDirectory(Path.Combine(variantFolder, "200")); + } + } + } + + FilePickerHelper.OpenFolderInFileManager(targetFolderPath); + Log.Information("Opened ghost folder for track '{TrackName}': {FolderPath}", trackName, targetFolderPath); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to open ghost folder for track: {TrackName}", trackName); + } + } + + private void ScanGhostsFolder() + { + _cachedTrackGhosts.Clear(); + + try + { + var ghostsPath = PathManager.GhostsFolderPath; + + if (!Directory.Exists(ghostsPath)) + { + Log.Information("Ghosts folder does not exist: {Path}", ghostsPath); + return; + } + + var hexFolders = Directory.GetDirectories(ghostsPath) + .Where(dir => IsValidHexFolder(Path.GetFileName(dir))) + .ToList(); + + Log.Information("Scanning {FolderCount} hex folders in ghosts directory", hexFolders.Count); + + foreach (var hexFolder in hexFolders) + { + var hexName = Path.GetFileName(hexFolder).ToLowerInvariant(); + var trackGhosts = ScanHexFolder(hexFolder, hexName); + + if (trackGhosts?.HasGhosts == true) + { + _cachedTrackGhosts[trackGhosts.TrackId] = trackGhosts; + } + } + } + catch (Exception ex) + { + Log.Error(ex, "Error scanning ghosts folder: {Path}", PathManager.GhostsFolderPath); + } + } + + private LocalTrackGhosts? ScanTrackFolder(uint trackId, string hexValue) + { + try + { + var hexFolder = Path.Combine(PathManager.GhostsFolderPath, hexValue.ToLowerInvariant()); + + if (!Directory.Exists(hexFolder)) + return null; + + return ScanHexFolder(hexFolder, hexValue.ToLowerInvariant()); + } + catch (Exception ex) + { + Log.Error(ex, "Error scanning track folder for track {TrackId}, hex {HexValue}", trackId, hexValue); + return null; + } + } + + private LocalTrackGhosts? ScanHexFolder(string hexFolderPath, string hexName) + { + var trackGhosts = new LocalTrackGhosts + { + HexFolderName = hexName + }; + + try + { + ScanCcFolder(Path.Combine(hexFolderPath, "150"), trackGhosts.Ghosts150, 150, false); + ScanCcFolder(Path.Combine(hexFolderPath, "200"), trackGhosts.Ghosts200, 200, false); + + var variantPath = Path.Combine(hexFolderPath, "1"); + if (Directory.Exists(variantPath)) + { + ScanCcFolder(Path.Combine(variantPath, "150"), trackGhosts.VariantGhosts150, 150, true); + ScanCcFolder(Path.Combine(variantPath, "200"), trackGhosts.VariantGhosts200, 200, true); + } + + var firstGhost = trackGhosts.Ghosts150.FirstOrDefault() ?? + trackGhosts.Ghosts200.FirstOrDefault() ?? + trackGhosts.VariantGhosts150.FirstOrDefault() ?? + trackGhosts.VariantGhosts200.FirstOrDefault(); + + if (firstGhost != null) + { + trackGhosts.TrackId = firstGhost.TrackId; + trackGhosts.TrackName = firstGhost.TrackName; + } + + return trackGhosts.HasGhosts ? trackGhosts : null; + } + catch (Exception ex) + { + Log.Error(ex, "Error scanning hex folder: {FolderPath}", hexFolderPath); + return null; + } + } + + private void ScanCcFolder(string ccFolderPath, List ghostList, int cc, bool isVariant) + { + try + { + if (!Directory.Exists(ccFolderPath)) + { + Log.Debug("CC folder does not exist: {FolderPath}", ccFolderPath); + return; + } + + var rkgFiles = Directory.GetFiles(ccFolderPath, "*.rkg", SearchOption.TopDirectoryOnly); + Log.Debug("Found {FileCount} .rkg files in {FolderPath}", rkgFiles.Length, ccFolderPath); + + var successfullyParsed = 0; + foreach (var rkgFile in rkgFiles) + { + Log.Debug("Attempting to parse RKG file: {FilePath}", rkgFile); + var ghostData = RkgParser.ParseRkgFile(rkgFile); + if (ghostData != null) + { + ghostData.Cc = cc; + ghostData.IsVariant = isVariant; + ghostList.Add(ghostData); + successfullyParsed++; + Log.Debug("Successfully parsed RKG: {FilePath} -> Track: {TrackName}, Time: {Time}", + rkgFile, ghostData.TrackName, ghostData.TotalTimeDisplay); + } + else + { + Log.Warning("Failed to parse RKG file: {FilePath}", rkgFile); + } + } + + Log.Debug("Parsed {SuccessCount}/{TotalCount} RKG files in {FolderPath}", + successfullyParsed, rkgFiles.Length, ccFolderPath); + } + catch (Exception ex) + { + Log.Error(ex, "Error scanning CC folder: {FolderPath}", ccFolderPath); + } + } + + private static bool IsValidHexFolder(string folderName) + { + return !string.IsNullOrEmpty(folderName) && + folderName.Length == 8 && + folderName.All(c => char.IsDigit(c) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')); + } + + /// + /// Force a rescan of the ghosts folder (clears cache) + /// + public void RefreshGhostData() + { + _cachedTrackGhosts.Clear(); + _hasScannedFolder = false; + Log.Information("Cleared local ghost cache, will rescan on next request"); + } +} \ No newline at end of file diff --git a/WheelWizard/Services/PathManager.cs b/WheelWizard/Services/PathManager.cs index b53ea83f..4ae4e695 100644 --- a/WheelWizard/Services/PathManager.cs +++ b/WheelWizard/Services/PathManager.cs @@ -83,6 +83,7 @@ public static bool IsUsingCustomWheelWizardAppdataPath public static string WiiDbFolder => Path.Combine(WiiFolderPath, "shared2", "menu", "FaceLib"); public static string MiiDbFile => Path.Combine(WiiDbFolder, "RFL_DB.dat"); public static string RRratingFilePath => Path.Combine(WiiFolderPath, "shared2", "Pulsar", "RetroRewind6", "RRRating.pul"); + public static string GhostsFolderPath => Path.Combine(WiiFolderPath, "shared2", "Pulsar", "RetroRewind6", "Ghosts"); #region Wheel Wizard Appdata Override diff --git a/WheelWizard/Services/RkgParser.cs b/WheelWizard/Services/RkgParser.cs new file mode 100644 index 00000000..11ad8495 --- /dev/null +++ b/WheelWizard/Services/RkgParser.cs @@ -0,0 +1,325 @@ +using WheelWizard.Models; +using System.Text; +using Serilog; + +namespace WheelWizard.Services; + +public class RkgParser +{ + public static LocalGhostData? ParseRkgFile(string filePath) + { + try + { + if (!File.Exists(filePath)) + { + Log.Debug("RKG file does not exist: {FilePath}", filePath); + return null; + } + + var data = File.ReadAllBytes(filePath); + Log.Debug("Reading RKG file: {FilePath}, Size: {Size} bytes", filePath, data.Length); + + if (data.Length < 0x88) + { + Log.Warning("RKG file too small ({Size} bytes, minimum 136): {FilePath}", data.Length, filePath); + return null; + } + + if (!IsValidRkgFile(data)) + { + Log.Warning("Invalid RKG header in file: {FilePath}", filePath); + return null; + } + + var ghostData = new LocalGhostData + { + FilePath = filePath, + TrackId = ParseTrackId(data), + CharacterId = ParseCharacterId(data), + VehicleId = ParseVehicleId(data), + ControllerType = ParseControllerType(data), + TotalTimeMs = ParseFinishTime(data), + Date = ParseDate(data, filePath), + LapSplitsMs = ParseLapSplits(data), + Country = ParseCountryCode(data), + MiiData = ParseMiiData(data) + }; + + Log.Debug("RKG Parse Results: TrackId={TrackId}, CharId={CharId}, VehicleId={VehicleId}, ControllerType={ControllerType}, Time={Time}ms, Date={Date}, Country={Country}, Mii='{Mii}'", + ghostData.TrackId, ghostData.CharacterId, ghostData.VehicleId, ghostData.ControllerType, + ghostData.TotalTimeMs, ghostData.Date, ghostData.Country, ghostData.MiiData); + + Log.Debug("Raw bytes: 0x04={B04:X2}, 0x05={B05:X2}, 0x06={B06:X2}, 0x07={B07:X2}, 0x08={B08:X2}, 0x09={B09:X2}, 0x0A={B0A:X2}, 0x0B={B0B:X2}, 0x10={B10:X2}", + data[0x04], data[0x05], data[0x06], data[0x07], data[0x08], data[0x09], data[0x0A], data[0x0B], data[0x10]); + + var timeMinutes = ExtractBits(data, 0x04, 0, 7); + var timeSeconds = ExtractBits(data, 0x04, 7, 7); + var timeMs = ExtractBits(data, 0x05, 6, 10); + Log.Debug("Time bit extraction: {Minutes}m {Seconds}s {Milliseconds}ms", timeMinutes, timeSeconds, timeMs); + + var vehicleId = ExtractBits(data, 0x08, 0, 6); + var characterId = ExtractBits(data, 0x08, 6, 6); + Log.Debug("Character/Vehicle extraction: VehicleId={VehicleId}, CharacterId={CharacterId}", vehicleId, characterId); + + Log.Debug("Successfully parsed RKG: {FilePath} -> TrackId: 0x{TrackId:X8}, Time: {Time}ms, Laps: {LapCount}", + filePath, ghostData.TrackId, ghostData.TotalTimeMs, ghostData.LapSplitsMs.Count); + + return ghostData; + } + catch (Exception ex) + { + Log.Warning(ex, "Error parsing RKG file {FilePath}: {Message}", filePath, ex.Message); + return null; + } + } + + private static bool IsValidRkgFile(byte[] data) + { + // RKG files should start with "RKGD" magic bytes + return data.Length >= 4 && + data[0] == 0x52 && data[1] == 0x4B && data[2] == 0x47 && data[3] == 0x44; // "RKGD" + } + + /// + /// Bit reader class for continuous bitstream reading + /// + private class BitReader + { + private readonly byte[] data; + private int bitOffset; + + public BitReader(byte[] data, int startByteOffset = 0) + { + this.data = data; + this.bitOffset = startByteOffset * 8; + } + + public uint ReadBits(int bitCount) + { + uint result = 0; + + for (int i = 0; i < bitCount; i++) + { + int byteIndex = bitOffset / 8; + int bitIndex = bitOffset % 8; + + if (byteIndex >= data.Length) break; + + uint bit = (uint)((data[byteIndex] >> (7 - bitIndex)) & 1); + result = (result << 1) | bit; + + bitOffset++; + } + + return result; + } + } + + /// + /// Extract bits from byte array with precise bit-level control + /// + private static uint ExtractBits(byte[] data, int byteOffset, int bitOffset, int bitCount) + { + uint result = 0; + int currentByte = byteOffset; + int currentBit = bitOffset; + + for (int i = 0; i < bitCount; i++) + { + if (currentByte >= data.Length) break; + + uint bit = (uint)((data[currentByte] >> (7 - currentBit)) & 1); + result = (result << 1) | bit; + + currentBit++; + if (currentBit >= 8) + { + currentBit = 0; + currentByte++; + } + } + + return result; + } + + /// + /// Parse track ID from offset 0x07 (6 bits, bits 0-5) + /// + private static uint ParseTrackId(byte[] data) + { + return ExtractBits(data, 0x07, 0, 6); // 6 bits starting at bit 0 of byte 0x07 + } + + /// + /// Parse vehicle ID from offset 0x08 (6 bits, bits 0-5) + /// + private static byte ParseVehicleId(byte[] data) + { + return (byte)ExtractBits(data, 0x08, 0, 6); // 6 bits starting at bit 0 of byte 0x08 + } + + /// + /// Parse character ID from offset 0x08 (next 6 bits after vehicle ID) + /// + private static byte ParseCharacterId(byte[] data) + { + return (byte)ExtractBits(data, 0x08, 6, 6); // 6 bits starting at bit 6 of byte 0x08 + } + + /// + /// Parse controller type from offset 0x0B.4 (4 bits, upper 4 bits) + /// + private static byte ParseControllerType(byte[] data) + { + return (byte)ExtractBits(data, 0x0B, 0, 4); // 4 bits starting at bit 0 of byte 0x0B + } + + /// + /// Parse finish time using precise bit extraction + /// 7 bits minutes + 7 bits seconds + 10 bits milliseconds = 24 bits total starting at 0x04 + /// + private static uint ParseFinishTime(byte[] data) + { + // Extract time fields using bit-level precision + uint minutes = ExtractBits(data, 0x04, 0, 7); // 7 bits for minutes + uint seconds = ExtractBits(data, 0x04, 7, 7); // Next 7 bits for seconds + uint milliseconds = ExtractBits(data, 0x05, 6, 10); // Next 10 bits for milliseconds + + return (minutes * 60 * 1000) + (seconds * 1000) + milliseconds; + } + + /// + /// Parse date using precise bit extraction + /// 7 bits year + 4 bits month + 5 bits day starting at 0x09.4 + /// + private static DateTime ParseDate(byte[] data, string filePath) + { + try + { + uint year = 2000 + ExtractBits(data, 0x09, 4, 7); // 7 bits starting at bit 4 of 0x09 + uint month = ExtractBits(data, 0x0A, 3, 4); // 4 bits starting at bit 3 of 0x0A + uint day = ExtractBits(data, 0x0A, 7, 5); // 5 bits starting at bit 7 of 0x0A (spans to 0x0B) + + // Validate date ranges + if (year >= 2000 && year <= DateTime.Now.Year + 1 && + month >= 1 && month <= 12 && + day >= 1 && day <= 31) + { + return new DateTime((int)year, (int)month, (int)day); + } + + Log.Debug("Invalid date in RKG: Year={Year}, Month={Month}, Day={Day}", year, month, day); + } + catch (Exception ex) + { + Log.Debug(ex, "Failed to parse date from RKG file"); + } + + return File.GetCreationTime(filePath); + } + + /// + /// Parse lap splits from offset 0x11 using continuous bitstream reading + /// Each split: 7 bits minutes + 7 bits seconds + 10 bits milliseconds (24 bits total) + /// CRITICAL: Must read as continuous bitstream, NOT byte-aligned! + /// + private static List ParseLapSplits(byte[] data) + { + var lapSplits = new List(); + + // Get lap count from offset 0x10 + if (data.Length <= 0x10) return lapSplits; + int lapCount = Math.Min((int)data[0x10], 5); // Max 5 lap splits stored + + // Create bit reader starting at offset 0x11 + var bitReader = new BitReader(data, 0x11); + + Log.Debug("Parsing {LapCount} lap splits starting at offset 0x11", lapCount); + + for (int i = 0; i < lapCount; i++) + { + // Read exactly 24 bits per lap split (7+7+10) + uint minutes = bitReader.ReadBits(7); + uint seconds = bitReader.ReadBits(7); + uint milliseconds = bitReader.ReadBits(10); + + uint lapTimeMs = (minutes * 60 * 1000) + (seconds * 1000) + milliseconds; + + Log.Debug("Lap {Index}: {Minutes}m {Seconds}s {Milliseconds}ms = {TotalMs}ms", + i + 1, minutes, seconds, milliseconds, lapTimeMs); + + if (lapTimeMs > 0) + { + lapSplits.Add(lapTimeMs); + } + } + + return lapSplits; + } + + /// + /// Parse country code from offset 0x34 + /// + private static ushort ParseCountryCode(byte[] data) + { + if (0x34 < data.Length) + return data[0x34]; + return 0xFF; + } + + /// + /// Parse Mii data from offset 0x3C (74 bytes) + /// The Mii name is stored as UTF-16 Big Endian at offset 0x02 within the Mii structure (20 bytes, 10 characters max) + /// + private static string ParseMiiData(byte[] data) + { + try + { + const int miiDataOffset = 0x3C; + const int nameOffsetInMii = 0x02; + const int nameLength = 20; // 10 UTF-16 characters = 20 bytes + + if (data.Length < miiDataOffset + nameOffsetInMii + nameLength) + { + Log.Debug("RKG file too small for Mii name data"); + return "Unknown"; + } + + var nameBytes = new byte[nameLength]; + Array.Copy(data, miiDataOffset + nameOffsetInMii, nameBytes, 0, nameLength); + + Log.Debug("Mii name bytes: {Bytes}", Convert.ToHexString(nameBytes)); + + var miiName = Encoding.BigEndianUnicode.GetString(nameBytes) + .TrimEnd('\0') + .TrimEnd(); + + if (!string.IsNullOrEmpty(miiName)) + { + miiName = new string(miiName.Where(c => !char.IsControl(c) && c != '\0' && (c >= 32 && c <= 126 || c > 127)).ToArray()); + } + + if (string.IsNullOrWhiteSpace(miiName) || miiName.All(c => c < 32 || (c > 127 && c < 160))) + { + Log.Debug("UTF-16 BE failed, trying UTF-16 LE for Mii name"); + miiName = Encoding.Unicode.GetString(nameBytes) + .TrimEnd('\0') + .TrimEnd(); + + if (!string.IsNullOrEmpty(miiName)) + { + miiName = new string(miiName.Where(c => !char.IsControl(c) && c != '\0' && (c >= 32 && c <= 126 || c > 127)).ToArray()); + } + } + + Log.Debug("Parsed Mii name: '{Name}' (length: {Length})", miiName, miiName?.Length ?? 0); + + return string.IsNullOrWhiteSpace(miiName) ? "Unknown" : miiName.Trim(); + } + catch (Exception ex) + { + Log.Debug(ex, "Error parsing Mii data"); + return "Unknown"; + } + } +} \ No newline at end of file diff --git a/WheelWizard/Services/TrackHexMappingService.cs b/WheelWizard/Services/TrackHexMappingService.cs new file mode 100644 index 00000000..0ee07f87 --- /dev/null +++ b/WheelWizard/Services/TrackHexMappingService.cs @@ -0,0 +1,341 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using WheelWizard.Helpers; +using WheelWizard.Models; +using Serilog; + +namespace WheelWizard.Services; + +/// +/// Resolves ghost folder hashes by grouping tracks via courseId and hashing main SZS bytes. +/// +public class TrackHexMappingService +{ + private const int CourseIdBase = 256; + private static readonly Regex ColorCodeRegex = new("\\\\c\\{[^}]+\\}", RegexOptions.Compiled); + private static readonly Regex MappingLineRegex = new("^(?.+?)\\s*=\\s*(?[0-9A-Fa-f]{8})$", RegexOptions.Compiled); + private static readonly Regex NonAlphaNumRegex = new("[^a-z0-9]+", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private readonly Dictionary _trackNameToHexMap = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _courseIdToHexMap = new(); + private readonly Dictionary _trackNameToCourseIdMap = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _courseIdToTrackNamesMap = new(); + private readonly object _lock = new(); + + private Dictionary? _szsIndex; + private Dictionary? _folderToTrackNameMap; + private bool _initialized; + + public void InitializeFromTrackInfo(IEnumerable trackInfos) + { + var tracks = trackInfos? + .Where(t => t.CourseId > 0) + .ToList() ?? new List(); + + if (tracks.Count == 0) + { + Log.Warning("Track hash initialization skipped: no valid track info entries were provided"); + return; + } + + lock (_lock) + { + _trackNameToHexMap.Clear(); + _courseIdToHexMap.Clear(); + _trackNameToCourseIdMap.Clear(); + _courseIdToTrackNamesMap.Clear(); + _szsIndex = null; + _folderToTrackNameMap = null; + + foreach (var group in tracks.GroupBy(t => t.CourseId)) + { + var courseId = group.Key; + _courseIdToTrackNamesMap[courseId] = group + .OrderBy(t => t.Id) + .Select(t => t.Name) + .ToList(); + + foreach (var track in group) + _trackNameToCourseIdMap[track.Name] = courseId; + } + + _initialized = true; + + Log.Information( + "Initialized lazy track hash metadata for {TrackCount} tracks across {CourseCount} course groups", + _trackNameToCourseIdMap.Count, + _courseIdToTrackNamesMap.Count); + } + } + + public string? GetHexValueForTrack(string trackName) + { + int courseId; + + lock (_lock) + { + _trackNameToHexMap.TryGetValue(trackName, out var hexValue); + if (hexValue != null) + return hexValue; + + if (!_trackNameToCourseIdMap.TryGetValue(trackName, out courseId)) + return null; + } + + return ResolveHexValueForCourse(courseId); + } + + public string? GetHexValueForTrack(string console, string trackName) + { + var fullTrackName = $"{console} {trackName}"; + return GetHexValueForTrack(fullTrackName); + } + + public Dictionary GetAllMappings() + { + lock (_lock) + { + return new Dictionary(_trackNameToHexMap, StringComparer.OrdinalIgnoreCase); + } + } + + public bool IsInitialized() + { + lock (_lock) + { + return _initialized; + } + } + + private static string ComputeGhostFolderHash(string szsPath) + { + var bytes = File.ReadAllBytes(szsPath); + var crc = CrcHelper.ComputeCrc32(bytes, 0, bytes.Length); + return crc.ToString("X8"); + } + + private string? ResolveHexValueForCourse(int courseId) + { + List? trackNames; + + lock (_lock) + { + if (_courseIdToHexMap.TryGetValue(courseId, out var cachedHash)) + return cachedHash; + + if (!_courseIdToTrackNamesMap.TryGetValue(courseId, out trackNames) || trackNames.Count == 0) + return null; + } + + EnsureLookupSourcesLoaded(); + + string? hash = null; + var slotIndex = courseId - CourseIdBase; + + if (slotIndex >= 0) + { + var mainFileName = $"{slotIndex}.szs"; + var szsIndex = GetSzsIndexSnapshot(); + + if (szsIndex.TryGetValue(mainFileName, out var mainSzsPath)) + { + try + { + hash = ComputeGhostFolderHash(mainSzsPath); + } + catch (Exception ex) + { + Log.Error(ex, + "Failed to compute ghost folder hash from SZS '{SzsPath}' for courseId {CourseId}", + mainSzsPath, + courseId); + } + } + } + + if (hash == null) + { + var folderMap = GetFolderToTrackNameMapSnapshot(); + foreach (var trackName in trackNames) + { + if (TryGetMappedHash(folderMap, trackName, out var mappedHash)) + { + hash = mappedHash; + break; + } + } + } + + if (hash == null) + { + Log.Warning( + "Could not resolve ghost hash for courseId {CourseId}; no slot file and no FolderToTrackName match", + courseId); + return null; + } + + lock (_lock) + { + _courseIdToHexMap[courseId] = hash; + foreach (var trackName in trackNames) + _trackNameToHexMap[trackName] = hash; + } + + Log.Debug("Resolved ghost hash {Hash} for courseId {CourseId} on demand", hash, courseId); + return hash; + } + + private void EnsureLookupSourcesLoaded() + { + lock (_lock) + { + if (_szsIndex != null && _folderToTrackNameMap != null) + return; + } + + var szsIndex = BuildSzsFileIndex(); + var folderToTrackNameMap = BuildFolderToTrackNameMap(); + + lock (_lock) + { + _szsIndex ??= szsIndex; + _folderToTrackNameMap ??= folderToTrackNameMap; + } + } + + private Dictionary GetSzsIndexSnapshot() + { + lock (_lock) + { + return _szsIndex ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + } + } + + private Dictionary GetFolderToTrackNameMapSnapshot() + { + lock (_lock) + { + return _folderToTrackNameMap ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + } + } + + private static Dictionary BuildSzsFileIndex() + { + var index = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var root in GetSzsSearchRoots()) + { + if (!Directory.Exists(root)) + continue; + + try + { + foreach (var file in Directory.EnumerateFiles(root, "*.szs", SearchOption.AllDirectories)) + { + var name = Path.GetFileName(file); + if (!index.ContainsKey(name)) + index[name] = file; + } + } + catch (Exception ex) + { + Log.Warning(ex, "Failed while indexing SZS files under {Root}", root); + } + } + + return index; + } + + private static Dictionary BuildFolderToTrackNameMap() + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + var ghostsRoot = Path.Combine(PathManager.RiivolutionWhWzFolderPath, "RetroRewind6", "Ghosts"); + var mappingFiles = new[] + { + Path.Combine(ghostsRoot, "FolderToTrackNameRT.txt"), + Path.Combine(ghostsRoot, "FolderToTrackNameCT.txt"), + Path.Combine(ghostsRoot, "FolderToTrackName.txt") + }; + + foreach (var file in mappingFiles) + { + if (!File.Exists(file)) + continue; + + try + { + foreach (var rawLine in File.ReadLines(file)) + { + var line = ColorCodeRegex.Replace(rawLine, string.Empty).Trim(); + if (line.Length == 0) + continue; + + var match = MappingLineRegex.Match(line); + if (!match.Success) + continue; + + var trackName = match.Groups["name"].Value.Trim(); + var hash = match.Groups["hex"].Value.ToUpperInvariant(); + + AddMappingIfMissing(map, trackName, hash); + AddMappingIfMissing(map, NormalizeTrackName(trackName), hash); + } + } + catch (Exception ex) + { + Log.Warning(ex, "Failed while parsing {MappingFile}", file); + } + } + + return map; + } + + private static bool TryGetMappedHash(Dictionary map, string trackName, out string? hash) + { + if (map.TryGetValue(trackName, out var directHash)) + { + hash = directHash; + return true; + } + + var normalized = NormalizeTrackName(trackName); + if (map.TryGetValue(normalized, out var normalizedHash)) + { + hash = normalizedHash; + return true; + } + + hash = null; + return false; + } + + private static string NormalizeTrackName(string value) + { + var lowered = value.ToLowerInvariant(); + var cleaned = NonAlphaNumRegex.Replace(lowered, " "); + return string.Join(' ', cleaned.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + } + + private static void AddMappingIfMissing(Dictionary map, string key, string hash) + { + if (key.Length == 0) + return; + + if (!map.ContainsKey(key)) + map[key] = hash; + } + + private static IEnumerable GetSzsSearchRoots() + { + var roots = new HashSet(StringComparer.OrdinalIgnoreCase) + { + Path.Combine(PathManager.RiivolutionWhWzFolderPath, "RetroRewind6"), + PathManager.MyStuffFolderPath, + PathManager.RrBetaFolderPath + }; + + return roots; + } +} diff --git a/WheelWizard/Services/TrackVariantMappingService.cs b/WheelWizard/Services/TrackVariantMappingService.cs new file mode 100644 index 00000000..a8dd59be --- /dev/null +++ b/WheelWizard/Services/TrackVariantMappingService.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using WheelWizard.Models; +using Serilog; + +namespace WheelWizard.Services; + +/// +/// Resolves variant/main track relationships by grouping API tracks with shared courseId. +/// +public class TrackVariantMappingService +{ + private readonly Dictionary _variantToMainTrackMap = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _variantTracks = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _mainTrackToVariantsMap = new(StringComparer.OrdinalIgnoreCase); + private readonly object _lock = new(); + + public void InitializeFromTrackInfo(IEnumerable trackInfos) + { + var tracks = trackInfos? + .Where(t => t.CourseId > 0) + .ToList() ?? new List(); + + if (tracks.Count == 0) + { + Log.Warning("Variant mapping initialization skipped: no valid track info entries were provided"); + return; + } + + lock (_lock) + { + _variantToMainTrackMap.Clear(); + _variantTracks.Clear(); + _mainTrackToVariantsMap.Clear(); + + foreach (var group in tracks.GroupBy(t => t.CourseId)) + { + var grouped = group.OrderBy(t => t.Id).ToList(); + var mainTrack = grouped.First(); + + if (!_mainTrackToVariantsMap.ContainsKey(mainTrack.Name)) + _mainTrackToVariantsMap[mainTrack.Name] = new List(); + + foreach (var track in grouped.Skip(1)) + { + _variantToMainTrackMap[track.Name] = mainTrack.Name; + _variantTracks.Add(track.Name); + _mainTrackToVariantsMap[mainTrack.Name].Add(track.Name); + } + } + + Log.Information( + "Initialized variant mapping with {VariantCount} variants under {MainCount} main tracks", + _variantToMainTrackMap.Count, + _mainTrackToVariantsMap.Count(kvp => kvp.Value.Count > 0)); + } + } + + public bool IsVariantTrack(string trackName) + { + lock (_lock) + { + return _variantTracks.Contains(trackName); + } + } + + public string GetMainTrackName(string variantTrackName) + { + lock (_lock) + { + if (_variantToMainTrackMap.TryGetValue(variantTrackName, out var mainTrack)) + return mainTrack; + + return variantTrackName; + } + } + + public List GetVariantsForMainTrack(string mainTrackName) + { + lock (_lock) + { + if (_mainTrackToVariantsMap.TryGetValue(mainTrackName, out var variants)) + return new List(variants); + + return new List(); + } + } + + public string? GetHexValueForTrack(string trackName, TrackHexMappingService trackHexMappingService) + { + var mainTrackName = GetMainTrackName(trackName); + return trackHexMappingService.GetHexValueForTrack(mainTrackName); + } + + public string? GetGhostFolderPath(string trackName, int cc, TrackHexMappingService trackHexMappingService, string ghostsBasePath) + { + var hexValue = GetHexValueForTrack(trackName, trackHexMappingService); + if (hexValue == null) + return null; + + var hexFolder = Path.Combine(ghostsBasePath, hexValue.ToLowerInvariant()); + return IsVariantTrack(trackName) + ? Path.Combine(hexFolder, "1", cc.ToString()) + : Path.Combine(hexFolder, cc.ToString()); + } + + public void AddVariantMapping(string mainTrackName, string variantTrackName) + { + lock (_lock) + { + _variantToMainTrackMap[variantTrackName] = mainTrackName; + _variantTracks.Add(variantTrackName); + + if (!_mainTrackToVariantsMap.ContainsKey(mainTrackName)) + _mainTrackToVariantsMap[mainTrackName] = new List(); + + if (!_mainTrackToVariantsMap[mainTrackName].Any(v => string.Equals(v, variantTrackName, StringComparison.OrdinalIgnoreCase))) + _mainTrackToVariantsMap[mainTrackName].Add(variantTrackName); + } + } + + public void RemoveVariantMapping(string variantTrackName) + { + lock (_lock) + { + if (!_variantToMainTrackMap.TryGetValue(variantTrackName, out var mainTrackName)) + return; + + _variantToMainTrackMap.Remove(variantTrackName); + _variantTracks.Remove(variantTrackName); + + if (_mainTrackToVariantsMap.TryGetValue(mainTrackName, out var variants)) + { + variants.RemoveAll(v => string.Equals(v, variantTrackName, StringComparison.OrdinalIgnoreCase)); + if (variants.Count == 0) + _mainTrackToVariantsMap.Remove(mainTrackName); + } + } + } + + public Dictionary GetAllVariantMappings() + { + lock (_lock) + { + return new Dictionary(_variantToMainTrackMap, StringComparer.OrdinalIgnoreCase); + } + } +} diff --git a/WheelWizard/SetupExtensions.cs b/WheelWizard/SetupExtensions.cs index 88f5c6bc..89366e9c 100644 --- a/WheelWizard/SetupExtensions.cs +++ b/WheelWizard/SetupExtensions.cs @@ -42,6 +42,14 @@ public static void AddWheelWizardServices(this IServiceCollection services) services.AddMiiImages(); services.AddCustomDistributionService(); + // Ghost services + services.AddHttpClient(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddTransient(); + services.AddSingleton(); + // IO Abstractions services.AddSingleton(); services.AddSingleton(); diff --git a/WheelWizard/Views/App.axaml b/WheelWizard/Views/App.axaml index 1b9991a0..822d1b62 100644 --- a/WheelWizard/Views/App.axaml +++ b/WheelWizard/Views/App.axaml @@ -67,6 +67,7 @@ + diff --git a/WheelWizard/Views/Components/LapTimesGraph.axaml b/WheelWizard/Views/Components/LapTimesGraph.axaml new file mode 100644 index 00000000..2a2a6be0 --- /dev/null +++ b/WheelWizard/Views/Components/LapTimesGraph.axaml @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/WheelWizard/Views/Components/LapTimesGraph.axaml.cs b/WheelWizard/Views/Components/LapTimesGraph.axaml.cs new file mode 100644 index 00000000..696fb5cb --- /dev/null +++ b/WheelWizard/Views/Components/LapTimesGraph.axaml.cs @@ -0,0 +1,249 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Shapes; +using Avalonia.Media; +using Avalonia.Threading; + +namespace WheelWizard.Views.Components; + +public partial class LapTimesGraph : UserControl, INotifyPropertyChanged +{ + private const double GraphWidth = 400; + private const double GraphHeight = 120; + + private List _lapTimesMs = new(); + private Geometry? _graphPath; + private Geometry? _graphAreaPath; + private string _fastestLapText = "--"; + private string _slowestLapText = "--"; + private string _averageLapText = "--"; + private bool _hasData; + + public static readonly StyledProperty?> LapTimesProperty = AvaloniaProperty.Register?>( + nameof(LapTimes), + new List() + ); + + static LapTimesGraph() + { + LapTimesProperty.Changed.AddClassHandler((graph, _) => graph.UpdateGraph()); + } + + public List? LapTimes + { + get => GetValue(LapTimesProperty); + set => SetValue(LapTimesProperty, value); + } + + public Geometry? GraphPath + { + get => _graphPath; + set + { + _graphPath = value; + OnPropertyChanged(nameof(GraphPath)); + } + } + + public Geometry? GraphAreaPath + { + get => _graphAreaPath; + set + { + _graphAreaPath = value; + OnPropertyChanged(nameof(GraphAreaPath)); + } + } + + public string FastestLapText + { + get => _fastestLapText; + set + { + _fastestLapText = value; + OnPropertyChanged(nameof(FastestLapText)); + } + } + + public string SlowestLapText + { + get => _slowestLapText; + set + { + _slowestLapText = value; + OnPropertyChanged(nameof(SlowestLapText)); + } + } + + public string AverageLapText + { + get => _averageLapText; + set + { + _averageLapText = value; + OnPropertyChanged(nameof(AverageLapText)); + } + } + + public bool HasData + { + get => _hasData; + set + { + _hasData = value; + OnPropertyChanged(nameof(HasData)); + } + } + + public LapTimesGraph() + { + InitializeComponent(); + } + + private void UpdateGraph() + { + var lapTimes = LapTimes; + if (lapTimes == null || lapTimes.Count == 0) + { + HasData = false; + ResetGraph(); + return; + } + + _lapTimesMs = lapTimes.ToList(); + + if (_lapTimesMs.Count == 0) + { + HasData = false; + ResetGraph(); + return; + } + + var minTime = _lapTimesMs.Min(); + var maxTime = _lapTimesMs.Max(); + var timeRange = Math.Max(1, maxTime - minTime); + var averageTime = _lapTimesMs.Average(); + + FastestLapText = FormatTime(minTime); + SlowestLapText = FormatTime(maxTime); + AverageLapText = FormatTime((int)averageTime); + + var points = new List(); + for (int i = 0; i < _lapTimesMs.Count; i++) + { + var x = (double)i / Math.Max(1, _lapTimesMs.Count - 1) * GraphWidth; + var y = GraphHeight - ((_lapTimesMs[i] - minTime) / (double)timeRange * GraphHeight); + points.Add(new Point(x, y)); + } + + if (_lapTimesMs.Count == 1) + { + points[0] = new Point(GraphWidth / 2, GraphHeight / 2); + } + + GraphPath = BuildLineGeometry(points); + GraphAreaPath = BuildAreaGeometry(points); + HasData = points.Count > 0; + + Dispatcher.UIThread.Post(() => UpdateDataPoints(points), DispatcherPriority.Loaded); + } + + private void UpdateDataPoints(IReadOnlyList points) + { + if (this.FindControl("DataPointsCanvas") is not Canvas canvas) + return; + + canvas.Children.Clear(); + + var fastestLapIndex = _lapTimesMs.IndexOf(_lapTimesMs.Min()); + + for (int i = 0; i < points.Count; i++) + { + var point = points[i]; + var circle = new Ellipse + { + Width = 6, + Height = 6, + Fill = i == fastestLapIndex ? App.Current?.Resources["Primary"] as IBrush : App.Current?.Resources["Neutral300"] as IBrush, + Stroke = App.Current?.Resources["Neutral950"] as IBrush, + StrokeThickness = 1 + }; + + Canvas.SetLeft(circle, point.X - 3); + Canvas.SetTop(circle, point.Y - 3); + canvas.Children.Add(circle); + } + } + + private void ResetGraph() + { + GraphPath = null; + GraphAreaPath = null; + FastestLapText = "--"; + SlowestLapText = "--"; + AverageLapText = "--"; + } + + private static string FormatTime(int milliseconds) + { + var totalSeconds = milliseconds / 1000.0; + var minutes = (int)totalSeconds / 60; + var seconds = totalSeconds % 60; + return minutes > 0 ? $"{minutes}:{seconds:00.000}" : $"{seconds:0.000}"; + } + + private static Geometry? BuildLineGeometry(IReadOnlyList points) + { + if (points.Count == 0) + return null; + + var figure = new PathFigure + { + StartPoint = points[0], + IsFilled = false, + IsClosed = false, + Segments = [], + }; + + for (var i = 1; i < points.Count; i++) + figure.Segments.Add(new LineSegment { Point = points[i] }); + + var geometry = new PathGeometry { Figures = [] }; + geometry.Figures.Add(figure); + return geometry; + } + + private static Geometry? BuildAreaGeometry(IReadOnlyList points) + { + if (points.Count == 0) + return null; + + var figure = new PathFigure + { + StartPoint = new Point(points[0].X, GraphHeight), + IsFilled = true, + IsClosed = true, + Segments = [], + }; + + foreach (var point in points) + figure.Segments.Add(new LineSegment { Point = point }); + + figure.Segments.Add(new LineSegment { Point = new Point(points[^1].X, GraphHeight) }); + + var geometry = new PathGeometry { Figures = [] }; + geometry.Figures.Add(figure); + return geometry; + } + + public new event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} \ No newline at end of file diff --git a/WheelWizard/Views/Layout.axaml b/WheelWizard/Views/Layout.axaml index d2886b41..41697534 100644 --- a/WheelWizard/Views/Layout.axaml +++ b/WheelWizard/Views/Layout.axaml @@ -139,6 +139,9 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/WheelWizard/Views/Pages/GhostsPage.axaml.cs b/WheelWizard/Views/Pages/GhostsPage.axaml.cs new file mode 100644 index 00000000..9b7a171b --- /dev/null +++ b/WheelWizard/Views/Pages/GhostsPage.axaml.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Interactivity; +using WheelWizard.Models; +using WheelWizard.Services; +using WheelWizard.Views; +using WheelWizard.Views.Patterns; +using WheelWizard.Shared.DependencyInjection; +using Serilog; + +namespace WheelWizard.Views.Pages; + +public partial class GhostsPage : UserControlBase, INotifyPropertyChanged +{ + [Inject] private GhostTrackService _ghostTrackService { get; set; } = null!; + private ObservableCollection _tracks = new(); + private ObservableCollection _filteredTracks = new(); + private string _searchText = string.Empty; + private bool _isLoading = false; + + public ObservableCollection FilteredTracks + { + get => _filteredTracks; + set + { + _filteredTracks = value; + OnPropertyChanged(nameof(FilteredTracks)); + UpdateTrackCount(); + } + } + + public bool IsLoading + { + get => _isLoading; + set + { + _isLoading = value; + OnPropertyChanged(nameof(IsLoading)); + } + } + + public GhostsPage() + { + InitializeComponent(); + DataContext = this; + + TrackListBox.SelectionChanged += TrackListBox_SelectionChanged; + _ = LoadTracksAsync(); + } + + private void UpdateTrackCount() + { + if (TrackCount != null) + { + TrackCount.Text = _filteredTracks.Count.ToString(); + } + } + + private void TrackListBox_SelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (TrackListBox.SelectedItem != null) + { + TrackListBox.SelectedItem = null; + } + } + + private async Task LoadTracksAsync() + { + try + { + IsLoading = true; + Log.Information("Loading tracks from API"); + + var allTracks = await _ghostTrackService.GetAllTracksAsync(); + var trackFilter = GetSelectedTrackFilter(); + var locationFilter = GetSelectedLocationFilter(); + + var filteredTracks = _ghostTrackService.GetFilteredTracks(trackFilter, locationFilter, _searchText); + + _tracks = new ObservableCollection(allTracks); + FilteredTracks = new ObservableCollection(filteredTracks); + + Log.Information("Loaded {Count} tracks", allTracks.Count); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to load tracks"); + } + finally + { + IsLoading = false; + } + } + + private void ApplySearchFilter() + { + if (_tracks == null) + { + FilteredTracks = new ObservableCollection(); + return; + } + + var trackFilter = GetSelectedTrackFilter(); + var locationFilter = GetSelectedLocationFilter(); + var filteredTracks = _ghostTrackService.GetFilteredTracks(trackFilter, locationFilter, _searchText); + + FilteredTracks = new ObservableCollection(filteredTracks); + } + + private GhostTrackType GetSelectedTrackFilter() + { + if (RetroTracksRadio?.IsChecked == true) return GhostTrackType.Retro; + if (CustomTracksRadio?.IsChecked == true) return GhostTrackType.Custom; + return GhostTrackType.All; + } + + private GhostLocation GetSelectedLocationFilter() + { + return GhostLocation.All; + } + + private void FilterChanged(object? sender, RoutedEventArgs e) + { + if (_isLoading) return; + ApplySearchFilter(); + } + + private void SearchTextBox_TextChanged(object? sender, Avalonia.Controls.TextChangedEventArgs e) + { + if (sender is TextBox textBox) + { + _searchText = textBox.Text ?? string.Empty; + ApplySearchFilter(); + } + } + + private void ViewTimesButton_Click(object? sender, RoutedEventArgs e) + { + if (sender is Button button && button.Tag is ApiTrack track) + { + var ghostTrack = new GhostTrack + { + TrackId = track.TrackId, + Name = track.Name, + Console = track.Console, + TrackType = track.TrackType, + HexValue = track.HexValue + }; + NavigationManager.NavigateTo(ghostTrack); + } + } + + public new event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} \ No newline at end of file diff --git a/WheelWizard/Views/Patterns/TrackListItem.axaml b/WheelWizard/Views/Patterns/TrackListItem.axaml new file mode 100644 index 00000000..a3ffbc93 --- /dev/null +++ b/WheelWizard/Views/Patterns/TrackListItem.axaml @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/WheelWizard/Views/Patterns/TrackListItem.axaml.cs b/WheelWizard/Views/Patterns/TrackListItem.axaml.cs new file mode 100644 index 00000000..032d6afb --- /dev/null +++ b/WheelWizard/Views/Patterns/TrackListItem.axaml.cs @@ -0,0 +1,117 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; + +namespace WheelWizard.Views.Patterns; + +public class TrackListItem : TemplatedControl +{ + public static readonly StyledProperty TrackNameProperty = + AvaloniaProperty.Register(nameof(TrackName), string.Empty); + + public static readonly StyledProperty ConsoleProperty = + AvaloniaProperty.Register(nameof(Console), string.Empty); + + public static readonly StyledProperty ConsoleColorProperty = + AvaloniaProperty.Register(nameof(ConsoleColor), string.Empty); + + public static readonly StyledProperty IsCustomTrackProperty = + AvaloniaProperty.Register(nameof(IsCustomTrack), false); + + public static readonly StyledProperty IsRetroTrackProperty = + AvaloniaProperty.Register(nameof(IsRetroTrack), false); + + public static readonly StyledProperty HexValueProperty = + AvaloniaProperty.Register(nameof(HexValue), string.Empty); + + public string TrackName + { + get => GetValue(TrackNameProperty); + set => SetValue(TrackNameProperty, value); + } + + public string Console + { + get => GetValue(ConsoleProperty); + set => SetValue(ConsoleProperty, value); + } + + public string ConsoleColor + { + get => GetValue(ConsoleColorProperty); + set => SetValue(ConsoleColorProperty, value); + } + + public bool IsCustomTrack + { + get => GetValue(IsCustomTrackProperty); + set => SetValue(IsCustomTrackProperty, value); + } + + public bool IsRetroTrack + { + get => GetValue(IsRetroTrackProperty); + set => SetValue(IsRetroTrackProperty, value); + } + + public string HexValue + { + get => GetValue(HexValueProperty); + set => SetValue(HexValueProperty, value); + } + + public event EventHandler? ViewTimesClick; + + private TextBlock? _consoleLabel; + private Border? _customBadge; + private Border? _retroBadge; + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + + var viewTimesButton = e.NameScope.Find