diff --git a/CURRENTRELEASE.md b/CURRENTRELEASE.md index 4d0349a..bf381b8 100644 --- a/CURRENTRELEASE.md +++ b/CURRENTRELEASE.md @@ -1,13 +1,25 @@ -# OpenSpartan Workshop 1.0.3 (`ESCHARUM-03052024`) +# OpenSpartan Workshop 1.0.4 (`URDIDACT-03112024`) -- [#2] Fixed a bug where the battle pass navigation was not correctly done on smaller window sizes. -- [#3] Medal name ID is now shown in medal details, so that a user can note it for SQL queries. -- [#5] Medals are now shown for each of the matches. -- User can navigate to matches where they earned medals from the match view. -- [#10] Ranked information displayed in match view and match details. -- [#12] **Battle Pass** is now listed as **Operations** in the navigation view. -- Fixed a bug where the service record might not correctly load. -- [#14] Title now correctly displays the tier and tier type. -- Onyx Cadet Grade 3 now correctly renders the large icon in the service record. Caused by Halo Infinite API game CMS misconfiguration. +- [#4] Battle pass item metadata is now shown whenever the user taps on an item. +- [#16] Tier counterfactuals are now displayed for all ranked matches. +- [#18] Ranked data now displayed in its own tab. +- [#21] Added an experimental loose search setting, that speeds up match updates. It's disabled by default but can significantly improve match update performance after matches are already populated in the database. +- [#23] Fixes missing medals not rendered in match stats. +- [#24] For matches where there are no expected kills or deaths, the performance arrow indicators are not shown. +- [#25] Fixed the issue where medals are not refreshed on application launch. +- [#27] Unnecessary medal label not rendered in match overview when no medals are earned. +- [#28] Add support for authenticating without using the authentication broker. +- [#31] Data from [The Exchange](https://www.halowaypoint.com/news/welcome-to-the-exchange) is now rendered in a separate tab, allowing convenient tracking of what's currently on the market. +- [#35] Event progress is now shown the same way you would see it for operations. +- Improvements to logging for match acquisition, enabling faster diagnostics for failed calls. +- Improvements to re-trying match acquisition for failed matches. +- Logging out now shows a dialog that matches the design of everything else in the window. +- Events are now listed alongside typical operations (battle pass progression). +- Minigame modes (e.g., [Survive The Undead](https://www.halowaypoint.com/news/combat-workshop-survive-the-undead)) now correctly render in the match list and don't show an integer representation. +- Improved logging - it's now easier to trace where exactly an issue surfaced. +- All currency now displays titles in the battle pass view. +- Fixed the bug where the nameplate was not being rendered even though the image is available. +- Matches in the matches list are now ordered by their end time, making it easier to parse the last games. +- Fixed an issue where Bronze ranks were not displayed in match metadata. Refer to [**getting started guide**](https://openspartan.com/docs/workshop/guides/get-started/) to start using OpenSpartan Workshop. diff --git a/src/OpenSpartan.Workshop.Installer.Bundle/Dependencies/WindowsAppRuntimeInstall-x64.exe b/src/OpenSpartan.Workshop.Installer.Bundle/Dependencies/WindowsAppRuntimeInstall-x64.exe index 0410e5e..33e1e15 100644 Binary files a/src/OpenSpartan.Workshop.Installer.Bundle/Dependencies/WindowsAppRuntimeInstall-x64.exe and b/src/OpenSpartan.Workshop.Installer.Bundle/Dependencies/WindowsAppRuntimeInstall-x64.exe differ diff --git a/src/OpenSpartan.Workshop/App.xaml b/src/OpenSpartan.Workshop/App.xaml index bbe1626..3954714 100644 --- a/src/OpenSpartan.Workshop/App.xaml +++ b/src/OpenSpartan.Workshop/App.xaml @@ -152,6 +152,19 @@ + + + + + + + + + + + + + diff --git a/src/OpenSpartan.Workshop/App.xaml.cs b/src/OpenSpartan.Workshop/App.xaml.cs index 98c8280..dc0f4ce 100644 --- a/src/OpenSpartan.Workshop/App.xaml.cs +++ b/src/OpenSpartan.Workshop/App.xaml.cs @@ -1,6 +1,7 @@ using Microsoft.UI.Xaml; using NLog; using OpenSpartan.Workshop.Core; +using OpenSpartan.Workshop.Models; using OpenSpartan.Workshop.ViewModels; using System; using System.IO; @@ -9,8 +10,6 @@ namespace OpenSpartan.Workshop { public partial class App : Application { - private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); - public Window MainWindow { get => m_window; } /// @@ -35,8 +34,7 @@ protected override async void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventA LogManager.Setup().LoadConfigurationFromFile("NLog.config"); LoadSettings(); - - var authResult = await UserContextManager.InitializeAllDataOnLaunch(); + _ = await UserContextManager.InitializeAllDataOnLaunch(); } private async static void LoadSettings() @@ -44,35 +42,46 @@ private async static void LoadSettings() var settingsPath = Path.Combine(Configuration.AppDataDirectory, Configuration.SettingsFileName); if (File.Exists(settingsPath)) { + // Make sure that we default logging to true prior to settings + // being loaded so that we can capture any errors that might be + // happening with settings initialization. SettingsViewModel.Instance.Settings = SettingsManager.LoadSettings(); - if (SettingsViewModel.Instance.Settings.SyncSettings) + + if ((bool)SettingsViewModel.Instance.Settings.SyncSettings) { try { var settings = await UserContextManager.GetWorkshopSettings(); + if (settings != null) { - // For now, we only have two settings that are returned by the API, so - // no need to overwrite the entire object. SettingsViewModel.Instance.Settings.Release = settings.Release; SettingsViewModel.Instance.Settings.HeaderImagePath = settings.HeaderImagePath; + SettingsViewModel.Instance.Settings.Build = settings.Build; + SettingsViewModel.Instance.Settings.Sandbox = settings.Sandbox; + SettingsViewModel.Instance.Settings.UseObanClearance = settings.UseObanClearance; } } catch (Exception ex) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error($"Could not load settings remotely. {ex.Message}\nWill use previously-configured settings.."); + LogEngine.Log($"Could not load settings remotely. {ex.Message}\nWill use previously-configured settings..", LogSeverity.Error); } } } else { - SettingsViewModel.Instance.Settings = new Models.WorkshopSettings + SettingsViewModel.Instance.Settings = new WorkshopSettings { APIVersion = Configuration.DefaultAPIVersion, HeaderImagePath = Configuration.DefaultHeaderImage, Release = Configuration.DefaultRelease, + Sandbox = Configuration.DefaultSandbox, + Build = Configuration.DefaultBuild, SyncSettings = true, EnableLogging = false, + UseBroker = true, + UseObanClearance = false, + EnableLooseMatchSearch = false, }; } } diff --git a/src/OpenSpartan.Workshop/Controls/MatchesGridControl.xaml b/src/OpenSpartan.Workshop/Controls/MatchesGridControl.xaml index a735a06..75c3c98 100644 --- a/src/OpenSpartan.Workshop/Controls/MatchesGridControl.xaml +++ b/src/OpenSpartan.Workshop/Controls/MatchesGridControl.xaml @@ -4,8 +4,7 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:models="using:OpenSpartan.Workshop.Models" - xmlns:converters="using:OpenSpartan.Workshop.Converters" - xmlns:winuiconverters="using:CommunityToolkit.WinUI.UI.Converters" + xmlns:winuiconverters="using:CommunityToolkit.WinUI.Converters" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:controls="using:CommunityToolkit.WinUI.UI.Controls" xmlns:localcontrols="using:OpenSpartan.Workshop.Controls" @@ -14,21 +13,9 @@ x:Name="MatchesGridControlEntity" Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"> - - - - - - - - - - + - - - - + - + @@ -66,7 +53,7 @@ - + @@ -81,7 +68,7 @@ - + @@ -125,7 +112,7 @@ - + @@ -232,12 +219,14 @@ - + + + @@ -266,15 +255,183 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + diff --git a/src/OpenSpartan.Workshop/Controls/MatchesGridControl.xaml.cs b/src/OpenSpartan.Workshop/Controls/MatchesGridControl.xaml.cs index 1bc7a2a..7c4bb97 100644 --- a/src/OpenSpartan.Workshop/Controls/MatchesGridControl.xaml.cs +++ b/src/OpenSpartan.Workshop/Controls/MatchesGridControl.xaml.cs @@ -1,10 +1,8 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; -using CommunityToolkit.WinUI.UI.Controls; using System.Collections; using OpenSpartan.Workshop.Core; -using System; -using Microsoft.UI.Xaml.Media; +using CommunityToolkit.WinUI.UI.Controls; namespace OpenSpartan.Workshop.Controls { @@ -39,7 +37,7 @@ public RelayCommand MedalNavigationCommand } public MatchesGridControl() - { + { this.InitializeComponent(); } diff --git a/src/OpenSpartan.Workshop/Controls/SeasonCalendarControl.xaml b/src/OpenSpartan.Workshop/Controls/SeasonCalendarControl.xaml new file mode 100644 index 0000000..2c0e191 --- /dev/null +++ b/src/OpenSpartan.Workshop/Controls/SeasonCalendarControl.xaml @@ -0,0 +1,63 @@ + + + + + + + + + + + diff --git a/src/OpenSpartan.Workshop/Controls/SeasonCalendarControl.xaml.cs b/src/OpenSpartan.Workshop/Controls/SeasonCalendarControl.xaml.cs new file mode 100644 index 0000000..d0ff1bb --- /dev/null +++ b/src/OpenSpartan.Workshop/Controls/SeasonCalendarControl.xaml.cs @@ -0,0 +1,64 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using OpenSpartan.Workshop.Models; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace OpenSpartan.Workshop.Controls +{ + + public sealed partial class SeasonCalendarControl : UserControl + { + public static readonly DependencyProperty DayItemsProperty = DependencyProperty.Register( + nameof(DayItems), + typeof(IEnumerable), + typeof(SeasonCalendarControl), + new PropertyMetadata(default)); + + public SeasonCalendarControl() + { + InitializeComponent(); + this.CalendarViewControl.CalendarViewDayItemChanging += CalendarViewControl_CalendarViewDayItemChanging; + } + + public IEnumerable DayItems + { + get => (IEnumerable)GetValue(DayItemsProperty); + set => SetValue(DayItemsProperty, value); + } + + private void CalendarViewControl_CalendarViewDayItemChanging(CalendarView sender, CalendarViewDayItemChangingEventArgs args) + { + if (args.Phase == 0) + { + // Register callback for next phase. + args.RegisterUpdateCallback(CalendarViewControl_CalendarViewDayItemChanging); + } + else if (args.Phase == 1) + { + // Blackout dates in the past, Sundays, and dates that are fully booked. + if (args.Item.Date < DateTimeOffset.Now) + { + args.Item.IsBlackout = true; + } + // Register callback for next phase. + args.RegisterUpdateCallback(CalendarViewControl_CalendarViewDayItemChanging); + } + + if (DayItems != null) + { + var item = DayItems.Where(x => DateOnly.FromDateTime(x.DateTime) == DateOnly.FromDateTime(args.Item.Date.Date)).FirstOrDefault(); + + if (item != null) + { + args.Item.DataContext = item; + } + else + { + args.Item.DataContext = null; + } + } + } + } +} diff --git a/src/OpenSpartan.Workshop/Converters/CommaAfterThousandsConverter.cs b/src/OpenSpartan.Workshop/Converters/CommaAfterThousandsConverter.cs index 3eec62d..878be26 100644 --- a/src/OpenSpartan.Workshop/Converters/CommaAfterThousandsConverter.cs +++ b/src/OpenSpartan.Workshop/Converters/CommaAfterThousandsConverter.cs @@ -1,5 +1,6 @@ using Microsoft.UI.Xaml.Data; using System; +using System.Globalization; namespace OpenSpartan.Workshop.Converters { @@ -7,7 +8,7 @@ internal sealed class CommaAfterThousandsConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, string language) { - return string.Format("{0:n0}", value); + return string.Format(CultureInfo.InvariantCulture, "{0:n0}", value); } public object ConvertBack(object value, Type targetType, object parameter, string language) diff --git a/src/OpenSpartan.Workshop/Converters/ComplexTimeToSimpleTimeConverter.cs b/src/OpenSpartan.Workshop/Converters/ComplexTimeToSimpleTimeConverter.cs index 35229f1..24e04b7 100644 --- a/src/OpenSpartan.Workshop/Converters/ComplexTimeToSimpleTimeConverter.cs +++ b/src/OpenSpartan.Workshop/Converters/ComplexTimeToSimpleTimeConverter.cs @@ -1,5 +1,6 @@ using Microsoft.UI.Xaml.Data; using System; +using System.Globalization; namespace OpenSpartan.Workshop.Converters { @@ -8,7 +9,7 @@ internal sealed class ComplexTimeToSimpleTimeConverter : IValueConverter public object Convert(object value, Type targetType, object parameter, string language) { TimeSpan interval = (TimeSpan)value; - return string.Format("{0:D2}d {1:D2}hr {2:D2}min {3:D2}sec", interval.Days, interval.Hours, interval.Minutes, interval.Seconds); + return string.Format(CultureInfo.InvariantCulture, "{0:D2}d {1:D2}hr {2:D2}min {3:D2}sec", interval.Days, interval.Hours, interval.Minutes, interval.Seconds); } public object ConvertBack(object value, Type targetType, object parameter, string language) diff --git a/src/OpenSpartan.Workshop/Converters/CsrToPathConverter.cs b/src/OpenSpartan.Workshop/Converters/CsrToPathConverter.cs new file mode 100644 index 0000000..4d336b2 --- /dev/null +++ b/src/OpenSpartan.Workshop/Converters/CsrToPathConverter.cs @@ -0,0 +1,32 @@ +using Den.Dev.Orion.Models.HaloInfinite; +using Microsoft.UI.Xaml.Data; +using System; +using System.IO; + +namespace OpenSpartan.Workshop.Converters +{ + internal class CsrToPathConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + Csr csr = (Csr)value; + string tierPath = string.Empty; + // Tier is zero-indexed, but the images are starting from 1, so we need to ensure + // that we increment the tier to get the right image. + if (!string.IsNullOrEmpty(csr.Tier)) + { + tierPath = Path.Combine(Core.Configuration.AppDataDirectory, "imagecache", "csr", $"{csr.Tier.ToLowerInvariant()}_{csr.SubTier + 1}.png"); + } + else + { + tierPath = Path.Combine(Core.Configuration.AppDataDirectory, "imagecache", "csr", $"unranked_{csr.InitialMeasurementMatches - csr.MeasurementMatchesRemaining}.png"); + } + return tierPath; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/OpenSpartan.Workshop/Converters/CsrToProgressConverter.cs b/src/OpenSpartan.Workshop/Converters/CsrToProgressConverter.cs new file mode 100644 index 0000000..540785f --- /dev/null +++ b/src/OpenSpartan.Workshop/Converters/CsrToProgressConverter.cs @@ -0,0 +1,36 @@ +using Den.Dev.Orion.Models.HaloInfinite; +using Microsoft.UI.Xaml.Data; +using System; + +namespace OpenSpartan.Workshop.Converters +{ + internal class CsrToProgressConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + Csr currentCsr = (Csr)value; + if (currentCsr != null) + { + // When the Value is -1 that means the user is not ranked - there is no + // progress to report on. + if (currentCsr.Value > -1) + { + return (double)currentCsr.Value / (double)currentCsr.NextTierStart; + } + else + { + return (double)0; + } + } + else + { + return (double)0; + } + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/OpenSpartan.Workshop/Converters/CsrToTextRankConverter.cs b/src/OpenSpartan.Workshop/Converters/CsrToTextRankConverter.cs new file mode 100644 index 0000000..cebfc5b --- /dev/null +++ b/src/OpenSpartan.Workshop/Converters/CsrToTextRankConverter.cs @@ -0,0 +1,30 @@ +using Den.Dev.Orion.Models.HaloInfinite; +using Microsoft.UI.Xaml.Data; +using System; + +namespace OpenSpartan.Workshop.Converters +{ + internal class CsrToTextRankConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + Csr csr = (Csr)value; + + // Tier is zero-indexed, but the images are starting from 1, so we need to ensure + // that we increment the tier to get the right image. + if (!string.IsNullOrEmpty(csr.Tier)) + { + return $"{csr.Tier} {csr.SubTier + 1}"; + } + else + { + return "Unranked"; + } + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/OpenSpartan.Workshop/Converters/CsrToTooltipValueConverter.cs b/src/OpenSpartan.Workshop/Converters/CsrToTooltipValueConverter.cs new file mode 100644 index 0000000..abcd4b6 --- /dev/null +++ b/src/OpenSpartan.Workshop/Converters/CsrToTooltipValueConverter.cs @@ -0,0 +1,36 @@ +using Den.Dev.Orion.Models.HaloInfinite; +using Microsoft.UI.Xaml.Data; +using System; + +namespace OpenSpartan.Workshop.Converters +{ + internal class CsrToTooltipValueConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + Csr currentCsr = (Csr)value; + if (currentCsr != null) + { + // When the Value is -1 that means the user is not ranked - there is no + // progress to report on. + if (currentCsr.Value > -1) + { + return $"{currentCsr.Value}/{currentCsr.NextTierStart} ({((double)currentCsr.Value/(double)currentCsr.NextTierStart)*100:0.00}%)"; + } + else + { + return "Unranked"; + } + } + else + { + return "Unranked"; + } + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/OpenSpartan.Workshop/Converters/DirectValueToPercentageStringConverter.cs b/src/OpenSpartan.Workshop/Converters/DirectValueToPercentageStringConverter.cs index d6b35ec..990e045 100644 --- a/src/OpenSpartan.Workshop/Converters/DirectValueToPercentageStringConverter.cs +++ b/src/OpenSpartan.Workshop/Converters/DirectValueToPercentageStringConverter.cs @@ -1,5 +1,6 @@ using Microsoft.UI.Xaml.Data; using System; +using System.Globalization; namespace OpenSpartan.Workshop.Converters { @@ -7,7 +8,7 @@ internal class DirectValueToPercentageStringConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, string language) { - return $"{System.Convert.ToDouble(value) / 100.0:P02}"; + return $"{System.Convert.ToDouble(value, CultureInfo.InvariantCulture) / 100.0:P02}"; } public object ConvertBack(object value, Type targetType, object parameter, string language) diff --git a/src/OpenSpartan.Workshop/Converters/DoubleToPercentageStringConverter.cs b/src/OpenSpartan.Workshop/Converters/DoubleToPercentageStringConverter.cs index 386d73e..79b3fbd 100644 --- a/src/OpenSpartan.Workshop/Converters/DoubleToPercentageStringConverter.cs +++ b/src/OpenSpartan.Workshop/Converters/DoubleToPercentageStringConverter.cs @@ -1,5 +1,6 @@ using Microsoft.UI.Xaml.Data; using System; +using System.Globalization; namespace OpenSpartan.Workshop.Converters { @@ -7,7 +8,7 @@ internal sealed class DoubleToPercentageStringConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, string language) { - return $"{System.Convert.ToDouble(value):P2}"; + return $"{System.Convert.ToDouble(value, CultureInfo.InvariantCulture):P2}"; } public object ConvertBack(object value, Type targetType, object parameter, string language) diff --git a/src/OpenSpartan.Workshop/Converters/ISO8601ToLocalDateStringConverter.cs b/src/OpenSpartan.Workshop/Converters/ISO8601ToLocalDateStringConverter.cs new file mode 100644 index 0000000..d65f8b0 --- /dev/null +++ b/src/OpenSpartan.Workshop/Converters/ISO8601ToLocalDateStringConverter.cs @@ -0,0 +1,24 @@ +using Microsoft.UI.Xaml.Data; +using System; +using System.Globalization; + +namespace OpenSpartan.Workshop.Converters +{ + public class ISO8601ToLocalDateStringConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is DateTime dateTime) + { + return dateTime.ToString("MMMM d, yyyy h:mm tt", CultureInfo.CurrentCulture); + } + + return value; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/OpenSpartan.Workshop/Converters/ListCountToVisibilityConverter.cs b/src/OpenSpartan.Workshop/Converters/ListCountToVisibilityConverter.cs new file mode 100644 index 0000000..e495f11 --- /dev/null +++ b/src/OpenSpartan.Workshop/Converters/ListCountToVisibilityConverter.cs @@ -0,0 +1,26 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; +using System; +using System.Collections; +using System.Linq; + +namespace OpenSpartan.Workshop.Converters +{ + internal class ListCountToVisibilityConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is IEnumerable enumerable) + { + return enumerable.Cast().Any() ? Visibility.Visible : Visibility.Collapsed; + } + + return Visibility.Collapsed; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/OpenSpartan.Workshop/Converters/MedalDifficultyToBrushConverter.cs b/src/OpenSpartan.Workshop/Converters/MedalDifficultyToBrushConverter.cs index 551c50f..0951151 100644 --- a/src/OpenSpartan.Workshop/Converters/MedalDifficultyToBrushConverter.cs +++ b/src/OpenSpartan.Workshop/Converters/MedalDifficultyToBrushConverter.cs @@ -1,6 +1,7 @@ using Microsoft.UI.Xaml.Data; using Microsoft.UI.Xaml.Media; using System; +using System.Globalization; namespace OpenSpartan.Workshop.Converters { @@ -8,7 +9,7 @@ internal sealed class MedalDifficultyToBrushConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, string language) { - int typeIndex = System.Convert.ToInt32(value); + int typeIndex = System.Convert.ToInt32(value, CultureInfo.InvariantCulture); var gCollection = new GradientStopCollection(); switch (typeIndex) diff --git a/src/OpenSpartan.Workshop/Converters/MedalTypeIndexToStringConverter.cs b/src/OpenSpartan.Workshop/Converters/MedalTypeIndexToStringConverter.cs index 4823258..f335146 100644 --- a/src/OpenSpartan.Workshop/Converters/MedalTypeIndexToStringConverter.cs +++ b/src/OpenSpartan.Workshop/Converters/MedalTypeIndexToStringConverter.cs @@ -1,5 +1,6 @@ using Microsoft.UI.Xaml.Data; using System; +using System.Globalization; namespace OpenSpartan.Workshop.Converters { @@ -7,7 +8,7 @@ internal sealed class MedalTypeIndexToStringConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, string language) { - int typeIndex = System.Convert.ToInt32(value); + int typeIndex = System.Convert.ToInt32(value, CultureInfo.InvariantCulture); return typeIndex switch { 0 => "Spree", diff --git a/src/OpenSpartan.Workshop/Converters/RewardTypeToStringConverter.cs b/src/OpenSpartan.Workshop/Converters/RewardTypeToStringConverter.cs new file mode 100644 index 0000000..b535056 --- /dev/null +++ b/src/OpenSpartan.Workshop/Converters/RewardTypeToStringConverter.cs @@ -0,0 +1,28 @@ +using Microsoft.UI.Xaml.Data; +using OpenSpartan.Workshop.Models; +using System; + +namespace OpenSpartan.Workshop.Converters +{ + internal class RewardTypeToStringConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + ItemMetadataContainer type = (ItemMetadataContainer)value; + return type.Type switch + { + ItemClass.XPGrant => "XP Grant", + ItemClass.SpartanPoints => "Spartan Points", + ItemClass.Credits => "Credits", + ItemClass.XPBoost => "XP Boost", + ItemClass.ChallengeReroll => "Challenge Swap", + _ => type.ItemDetails.CommonData.Title.Value, + }; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/OpenSpartan.Workshop/Converters/RewardTypeToVisibilityConverter.cs b/src/OpenSpartan.Workshop/Converters/RewardTypeToVisibilityConverter.cs new file mode 100644 index 0000000..e0c031e --- /dev/null +++ b/src/OpenSpartan.Workshop/Converters/RewardTypeToVisibilityConverter.cs @@ -0,0 +1,29 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; +using OpenSpartan.Workshop.Models; +using System; + +namespace OpenSpartan.Workshop.Converters +{ + internal class RewardTypeToVisibilityConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + ItemClass type = (ItemClass)value; + return type switch + { + ItemClass.XPGrant or + ItemClass.SpartanPoints or + ItemClass.Credits or + ItemClass.XPBoost or + ItemClass.ChallengeReroll => Visibility.Visible, + _ => Visibility.Collapsed, + }; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/OpenSpartan.Workshop/Converters/ShortValueAvailabilityToVisibilityConverter.cs b/src/OpenSpartan.Workshop/Converters/ShortValueAvailabilityToVisibilityConverter.cs new file mode 100644 index 0000000..be7e9b6 --- /dev/null +++ b/src/OpenSpartan.Workshop/Converters/ShortValueAvailabilityToVisibilityConverter.cs @@ -0,0 +1,27 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; +using System; + +namespace OpenSpartan.Workshop.Converters +{ + + internal sealed class SingleValueAvailabilityToVisibilityConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value != null && (float)value > 0) + { + return Visibility.Visible; + } + else + { + return Visibility.Collapsed; + } + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/OpenSpartan.Workshop/Core/ColorConverter.cs b/src/OpenSpartan.Workshop/Core/ColorConverter.cs new file mode 100644 index 0000000..77c3b0f --- /dev/null +++ b/src/OpenSpartan.Workshop/Core/ColorConverter.cs @@ -0,0 +1,31 @@ + +using Microsoft.UI.Xaml.Media; +using Windows.UI; + +namespace OpenSpartan.Workshop.Core +{ + internal class ColorConverter + { + public static SolidColorBrush FromHex(string hex) + { + // Remove any leading '#' characters + hex = hex.TrimStart('#'); + + // Parse the hexadecimal color string + byte a = 255; // Default alpha value + byte r = byte.Parse(hex.Substring(0, 2), System.Globalization.NumberStyles.HexNumber); + byte g = byte.Parse(hex.Substring(2, 2), System.Globalization.NumberStyles.HexNumber); + byte b = byte.Parse(hex.Substring(4, 2), System.Globalization.NumberStyles.HexNumber); + + // If the hex string has 8 characters, parse the alpha value + if (hex.Length == 8) + { + a = byte.Parse(hex.Substring(6, 2), System.Globalization.NumberStyles.HexNumber); + } + + var brush = new SolidColorBrush(Color.FromArgb(a, r, g, b)); + // Create a SolidColorBrush from the Color + return brush; + } + } +} diff --git a/src/OpenSpartan.Workshop/Core/Configuration.cs b/src/OpenSpartan.Workshop/Core/Configuration.cs index 87c847f..82a288b 100644 --- a/src/OpenSpartan.Workshop/Core/Configuration.cs +++ b/src/OpenSpartan.Workshop/Core/Configuration.cs @@ -3,18 +3,19 @@ namespace OpenSpartan.Workshop.Core { - internal class Configuration + internal sealed class Configuration { - // Endpoints + // Endpoint metadata. internal const string SettingsEndpoint = "https://wokrshop.api.openspartan.com/clientsettings"; internal const string HaloWaypointPlayerEndpoint = "https://www.halowaypoint.com/halo-infinite/players"; internal const string HaloWaypointCsrImageEndpoint = "https://www.halowaypoint.com/images/halo-infinite/csr/"; // Build-related metadata. - internal const string Version = "1.0.3"; - internal const string BuildId = "ESCHARUM-03052024"; + internal const string Version = "1.0.4"; + internal const string BuildId = "URDIDACT-03112024"; internal const string PackageName = "OpenSpartan.Workshop"; + // Authentication and setting-related metadata. internal static readonly string[] Scopes = ["Xboxlive.signin", "Xboxlive.offline_access"]; internal const string ClientID = "1079e683-7752-435a-aa4a-3cfdd700de82"; internal const string CacheFileName = "authcache.bin"; @@ -22,9 +23,11 @@ internal class Configuration internal static readonly string AppDataDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), PackageName); // API-related default metadata. - internal const string DefaultRelease = "1.6"; + internal const string DefaultRelease = "1.7"; internal const string DefaultAPIVersion = "1"; - internal const string DefaultHeaderImage = "progression/Switcher/Season_Switcher_S6_CSIII.png"; + internal const string DefaultHeaderImage = "progression/Switcher/Season_Switcher_S7_BH.png"; + internal const string DefaultSandbox = "UNUSED"; + internal const string DefaultBuild = "257116.24.04.18.1334-2"; // Rank markers used to download the rank images. internal static readonly string[] HaloInfiniteRanks = @@ -39,6 +42,12 @@ internal class Configuration "unranked_7", "unranked_8", "unranked_9", + "bronze_1", + "bronze_2", + "bronze_3", + "bronze_4", + "bronze_5", + "bronze_6", "silver_1", "silver_2", "silver_3", @@ -65,5 +74,31 @@ internal class Configuration "diamond_6", "onyx_1", ]; + + internal static readonly string[] SeasonColors = + [ + "#08B2E3", + "#EE6352", + "#57A773", + "#AB2346", + "#D5A021", + "#550527", + "#688E26", + "#F44708", + "#A10702", + "#38405F", + "#FF0035", + "#820263", + "#D90368", + "#170F11", + "#36C9C6", + "#D8A7CA", + "#7E3F8F", + "#402039", + "#313715", + "#390099", + "#1E555C", + "#941C2F", + ]; } } diff --git a/src/OpenSpartan.Workshop/Core/DateRangeParser.cs b/src/OpenSpartan.Workshop/Core/DateRangeParser.cs new file mode 100644 index 0000000..8b01330 --- /dev/null +++ b/src/OpenSpartan.Workshop/Core/DateRangeParser.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; + +namespace OpenSpartan.Workshop.Core +{ + internal class DateRangeParser + { + internal static List> ExtractDateRanges(string input) + { + // There is a typo in one of the date range definitions, so we want to + // work around it by replacing it with the proper string. + input = input.Replace("Febraury", "February", StringComparison.OrdinalIgnoreCase); + + string pattern = @"(?\w+)\s(?\d{1,2})(?:st|nd|rd|th)?,?\s(?\d{4})(?:\s-\s(?\w+)\s(?\d{1,2})(?:st|nd|rd|th)?,?\s(?\d{4}))?"; + Regex regex = new Regex(pattern); + string[] dateFormats = { "MMMM d, yyyy", "MMM d, yyyy", "MMMM d yyyy", "MMM d yyyy" }; + + return regex.Matches(input).Cast() + .Select(match => + { + string startDay = Regex.Replace(match.Groups["startDay"].Value, @"(st|nd|rd|th)", ""); + string endDay = Regex.Replace(match.Groups["endDay"].Value, @"(st|nd|rd|th)", ""); + + string startDateStr = $"{match.Groups["startMonth"].Value} {startDay}, {match.Groups["startYear"].Value}"; + string endDateStr = match.Groups["endMonth"].Success + ? $"{match.Groups["endMonth"].Value} {endDay}, {match.Groups["endYear"].Value}" + : startDateStr; + + if (DateTime.TryParseExact(startDateStr, dateFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime startDate) && + DateTime.TryParseExact(endDateStr, dateFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime endDate)) + { + return new Tuple(startDate, endDate); + } + else + { + throw new FormatException($"Invalid date format encountered. {input}"); + } + }) + .ToList(); + } + } +} diff --git a/src/OpenSpartan.Workshop/Core/Observable.cs b/src/OpenSpartan.Workshop/Core/Observable.cs index fff7e8a..2cdd1f6 100644 --- a/src/OpenSpartan.Workshop/Core/Observable.cs +++ b/src/OpenSpartan.Workshop/Core/Observable.cs @@ -5,9 +5,9 @@ namespace OpenSpartan.Workshop.Core { public class Observable : INotifyPropertyChanged { - public event PropertyChangedEventHandler PropertyChanged; + public event PropertyChangedEventHandler? PropertyChanged; - protected void Set(ref T storage, T value, [CallerMemberName] string propertyName = null) + protected void Set(ref T storage, T value, [CallerMemberName] string? propertyName = null) { if (Equals(storage, value)) { @@ -18,6 +18,6 @@ protected void Set(ref T storage, T value, [CallerMemberName] string property OnPropertyChanged(propertyName); } - protected void OnPropertyChanged(string propertyName) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + protected void OnPropertyChanged(string? propertyName) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } } \ No newline at end of file diff --git a/src/OpenSpartan.Workshop/Core/RelayCommand.cs b/src/OpenSpartan.Workshop/Core/RelayCommand.cs index 2ac46d0..7f1fe86 100644 --- a/src/OpenSpartan.Workshop/Core/RelayCommand.cs +++ b/src/OpenSpartan.Workshop/Core/RelayCommand.cs @@ -1,33 +1,29 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using System.Windows.Input; namespace OpenSpartan.Workshop.Core { public class RelayCommand : ICommand { - private readonly Action _execute; - private readonly Func _canExecute; + private readonly Action? _execute; + private readonly Func? _canExecute; - public event EventHandler CanExecuteChanged; + public event EventHandler? CanExecuteChanged; - public RelayCommand(Action execute, Func canExecute = null) + public RelayCommand(Action? execute, Func? canExecute = null) { _execute = execute ?? throw new ArgumentNullException(nameof(execute)); _canExecute = canExecute; } - public bool CanExecute(object parameter) + public bool CanExecute(object? parameter) { - return _canExecute == null || _canExecute((T)parameter); + return _canExecute == null || _canExecute((T)parameter!); } - public void Execute(object parameter) + public void Execute(object? parameter) { - _execute((T)parameter); + _execute!((T)parameter!); } public void RaiseCanExecuteChanged() diff --git a/src/OpenSpartan.Workshop/Core/SettingsManager.cs b/src/OpenSpartan.Workshop/Core/SettingsManager.cs index f09e8b2..66d251f 100644 --- a/src/OpenSpartan.Workshop/Core/SettingsManager.cs +++ b/src/OpenSpartan.Workshop/Core/SettingsManager.cs @@ -1,17 +1,16 @@ using NLog; using OpenSpartan.Workshop.Models; -using OpenSpartan.Workshop.ViewModels; using System; using System.IO; using System.Text.Json; namespace OpenSpartan.Workshop.Core { - internal class SettingsManager + internal sealed class SettingsManager { private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); - internal static WorkshopSettings LoadSettings() + internal static WorkshopSettings? LoadSettings() { try { @@ -19,13 +18,18 @@ internal static WorkshopSettings LoadSettings() } catch (Exception ex) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error($"Could not load settings. {ex.Message}"); + // Assume that here we cannot check for settings yet and we need to + // always log an error. We are also intentionally not using the log + // wrapper class here because we don't have the settings initialized yet. + Logger.Error($"Could not load settings. {ex.Message}"); return null; } } internal static bool StoreSettings(WorkshopSettings settings) { + ArgumentNullException.ThrowIfNull(settings); + try { if (settings != null) @@ -41,7 +45,7 @@ internal static bool StoreSettings(WorkshopSettings settings) } catch (Exception ex) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error($"Could not store settings. {ex.Message}"); + LogEngine.Log($"Could not store settings. {ex.Message}", LogSeverity.Error); return false; } } diff --git a/src/OpenSpartan.Workshop/Core/UserContextManager.cs b/src/OpenSpartan.Workshop/Core/UserContextManager.cs index 57cfd6a..7ea8491 100644 --- a/src/OpenSpartan.Workshop/Core/UserContextManager.cs +++ b/src/OpenSpartan.Workshop/Core/UserContextManager.cs @@ -6,35 +6,35 @@ using Den.Dev.Orion.Models.HaloInfinite; using Den.Dev.Orion.Models.Security; using Microsoft.Identity.Client; +using Microsoft.Identity.Client.Broker; using Microsoft.Identity.Client.Extensions.Msal; +using Microsoft.UI; using Microsoft.UI.Xaml; -using NLog; using OpenSpartan.Workshop.Data; using OpenSpartan.Workshop.Models; using OpenSpartan.Workshop.ViewModels; using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Globalization; using System.IO; using System.Linq; using System.Net.Http; using System.Net.Http.Headers; +using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using System.Transactions; namespace OpenSpartan.Workshop.Core { internal static class UserContextManager { - private static HaloApiResultContainer MedalMetadata; + private static MedalMetadata MedalMetadata; private const int MatchesPerPage = 25; - private static readonly NLog.Logger Logger = LogManager.GetCurrentClassLogger(); - private static readonly HttpClient WorkshopHttpClient = new() { BaseAddress = new Uri(Configuration.SettingsEndpoint), @@ -46,6 +46,8 @@ internal static class UserContextManager internal static CancellationTokenSource MatchLoadingCancellationTracker = new(); internal static CancellationTokenSource BattlePassLoadingCancellationTracker = new(); + internal static CancellationTokenSource ServiceRecordCancellationTracker = new(); + internal static CancellationTokenSource ExchangeCancellationTracker = new(); internal static MainWindow DispatcherWindow = ((Application.Current as App)?.MainWindow) as MainWindow; @@ -62,38 +64,50 @@ internal static nint GetMainWindowHandle() { return WinRT.Interop.WindowNative.GetWindowHandle(DispatcherWindow); } - - internal static async Task PrepopulateMedalMetadata() + + internal static async Task PrepopulateMedalMetadata() { try { - if (SettingsViewModel.Instance.EnableLogging) Logger.Info($"Attempting to populate medata metadata..."); - MedalMetadata = await SafeAPICall(async () => await HaloClient.GameCmsGetMedalMetadata()); - if (SettingsViewModel.Instance.EnableLogging) Logger.Info($"Medal metadata populated."); - return true; + LogEngine.Log($"Attempting to populate medata metadata..."); + + var metadata = await SafeAPICall(async () => await HaloClient.GameCmsGetMedalMetadata()); + if (metadata != null && metadata.Result != null) + { + return metadata.Result; + } + + LogEngine.Log($"Medal metadata populated."); + return null; } catch (Exception ex) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error($"Could not populate medal metadata. {ex.Message}"); - return false; + LogEngine.Log($"Could not populate medal metadata. {ex.Message}", LogSeverity.Error); + return null; } } internal static async Task InitializePublicClientApplication() { - BrokerOptions options = new(BrokerOptions.OperatingSystems.Windows) - { - Title = "OpenSpartan Workshop" - }; + var storageProperties = new StorageCreationPropertiesBuilder(Configuration.CacheFileName, Configuration.AppDataDirectory).Build(); - var pca = PublicClientApplicationBuilder + var pcaBootstrap = PublicClientApplicationBuilder .Create(Configuration.ClientID) - .WithAuthority(AadAuthorityAudience.PersonalMicrosoftAccount) - .WithParentActivityOrWindow(GetMainWindowHandle) - .WithBroker(options) - .Build(); + .WithAuthority(AadAuthorityAudience.PersonalMicrosoftAccount); + + if ((bool)SettingsViewModel.Instance.UseBroker) + { + BrokerOptions options = new(BrokerOptions.OperatingSystems.Windows) + { + Title = "OpenSpartan Workshop" + }; + + pcaBootstrap.WithParentActivityOrWindow(GetMainWindowHandle).WithBroker(options); + } + + var pca = pcaBootstrap.Build(); // This hooks up the cross-platform cache into MSAL var cacheHelper = await MsalCacheHelper.CreateAsync(storageProperties); @@ -119,7 +133,7 @@ internal static async Task InitializePublicClientApplicati catch (MsalClientException) { // Authentication was not successsful, we have no token. - if (SettingsViewModel.Instance.EnableLogging) Logger.Error("Authentication was not successful."); + LogEngine.Log("Authentication was not successful.", LogSeverity.Error); } } @@ -144,9 +158,11 @@ public static async Task GetWorkshopSettings() public static async Task> SafeAPICall(Func>> orionAPICall) { + HaloApiResultContainer result = null; + try { - var result = await orionAPICall(); + result = await orionAPICall(); if (result.Response.Code == 401) { @@ -154,7 +170,7 @@ public static async Task> SafeAP if (!tokenResult) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error("Could not reacquire tokens."); + LogEngine.Log("Could not reacquire tokens.", LogSeverity.Error); return default; } @@ -166,8 +182,8 @@ public static async Task> SafeAP } catch (Exception ex) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error($"Failed to make Halo Infinite API call. {ex.Message}"); - return null; + LogEngine.Log($"Failed to make Halo Infinite API call. {ex.Message}", LogSeverity.Error); + return result; } } @@ -208,19 +224,27 @@ internal static bool InitializeHaloClient(AuthenticationResult authResult) HaloClient = new(haloToken.Token, extendedTicket.DisplayClaims.Xui[0].XUID, userAgent: $"{Configuration.PackageName}/{Configuration.Version}-{Configuration.BuildId}"); - string localClearance = string.Empty; Task.Run(async () => { - var clearance = (await SafeAPICall(async () => { return await HaloClient.SettingsActiveClearance(SettingsViewModel.Instance.Settings.Release); })).Result; - if (clearance != null) + PlayerClearance? clearance = null; + + if ((bool)SettingsViewModel.Instance.Settings.UseObanClearance) + { + clearance = (await SafeAPICall(async () => { return await HaloClient.SettingsActiveFlight(SettingsViewModel.Instance.Settings.Sandbox, SettingsViewModel.Instance.Settings.Build, SettingsViewModel.Instance.Settings.Release); })).Result; + } + else + { + clearance = (await SafeAPICall(async () => { return await HaloClient.SettingsActiveClearance(SettingsViewModel.Instance.Settings.Release); })).Result; + } + + if (clearance != null && !string.IsNullOrWhiteSpace(clearance.FlightConfigurationId)) { - localClearance = clearance.FlightConfigurationId; - HaloClient.ClearanceToken = localClearance; - if (SettingsViewModel.Instance.EnableLogging) Logger.Info($"Your clearance is {localClearance} and it's set in the client."); + HaloClient.ClearanceToken = clearance.FlightConfigurationId; + LogEngine.Log($"Your clearance is {clearance.FlightConfigurationId} and it's set in the client."); } else { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error("Could not obtain the clearance."); + LogEngine.Log("Could not obtain the clearance.", LogSeverity.Error); } }).GetAwaiter().GetResult(); @@ -236,34 +260,32 @@ internal static async Task PopulateCareerData() { try { - var tasks = new List - { - SafeAPICall(async () => await HaloClient.EconomyGetPlayerCareerRank(new List { $"xuid({XboxUserContext.DisplayClaims.Xui[0].XUID})" }, "careerRank1")), - SafeAPICall(async () => await HaloClient.GameCmsGetCareerRanks("careerRank1")) - }; + var xuid = XboxUserContext.DisplayClaims.Xui[0].XUID; + var economyTask = SafeAPICall(() => HaloClient.EconomyGetPlayerCareerRank(new List { $"xuid({xuid})" }, "careerRank1")); + var ranksTask = SafeAPICall(() => HaloClient.GameCmsGetCareerRanks("careerRank1")); - await Task.WhenAll(tasks); + await Task.WhenAll(economyTask, ranksTask); - var careerTrackResult = (HaloApiResultContainer)tasks[0].GetResultOrDefault(); - var careerTrackContainerResult = (HaloApiResultContainer)tasks[1].GetResultOrDefault(); + var careerTrackResult = economyTask.GetResultOrDefault() as HaloApiResultContainer; + var careerTrackContainerResult = ranksTask.GetResultOrDefault() as HaloApiResultContainer; - if (careerTrackResult.Result != null && careerTrackResult.Response.Code == 200) + if (careerTrackResult?.Result != null && careerTrackResult.Response.Code == 200) { await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => HomeViewModel.Instance.CareerSnapshot = careerTrackResult.Result); } - if (careerTrackContainerResult.Result != null && (careerTrackContainerResult.Response.Code == 200 || careerTrackContainerResult.Response.Code == 304)) + if (careerTrackContainerResult?.Result != null && (careerTrackContainerResult.Response.Code == 200 || careerTrackContainerResult.Response.Code == 304)) { - await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => HomeViewModel.Instance.MaxRank = careerTrackContainerResult.Result.Ranks.Count); - - if (HomeViewModel.Instance.CareerSnapshot != null) + await DispatcherWindow.DispatcherQueue.EnqueueAsync(async () => { - var currentCareerStage = careerTrackContainerResult.Result.Ranks - .FirstOrDefault(c => c.Rank == HomeViewModel.Instance.CareerSnapshot.RewardTracks[0].Result.CurrentProgress.Rank + 1); + HomeViewModel.Instance.MaxRank = careerTrackContainerResult.Result.Ranks.Count; - if (currentCareerStage != null) + if (HomeViewModel.Instance.CareerSnapshot != null) { - await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => + var currentRank = HomeViewModel.Instance.CareerSnapshot.RewardTracks[0].Result.CurrentProgress.Rank + 1; + var currentCareerStage = careerTrackContainerResult.Result.Ranks.FirstOrDefault(c => c.Rank == currentRank); + + if (currentCareerStage != null) { HomeViewModel.Instance.Title = $"{currentCareerStage.TierType} {currentCareerStage.RankTitle.Value} {currentCareerStage.RankTier.Value}"; HomeViewModel.Instance.CurrentRankExperience = careerTrackResult.Result.RewardTracks[0].Result.CurrentProgress.PartialProgress; @@ -271,81 +293,111 @@ await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => HomeViewModel.Instance.ExperienceTotalRequired = careerTrackContainerResult.Result.Ranks.Sum(item => item.XpRequiredForRank); - var relevantRanks = careerTrackContainerResult.Result.Ranks - .Where(c => c.Rank <= HomeViewModel.Instance.CareerSnapshot.RewardTracks[0].Result.CurrentProgress.Rank); + var relevantRanks = careerTrackContainerResult.Result.Ranks.TakeWhile(c => c.Rank < currentRank); HomeViewModel.Instance.ExperienceEarnedToDate = relevantRanks.Sum(rank => rank.XpRequiredForRank) + careerTrackResult.Result.RewardTracks[0].Result.CurrentProgress.PartialProgress; - }); - // Currently a bug in the Halo Infinite CMS where the Onyx Cadet 3 large icon is set incorrectly. - // Hopefully at some point this will be fixed. - if (currentCareerStage.RankLargeIcon == "career_rank/CelebrationMoment/219_Cadet_Onyx_III.png") - { - currentCareerStage.RankLargeIcon = "career_rank/CelebrationMoment/19_Cadet_Onyx_III.png"; - } + // Currently a bug in the Halo Infinite CMS where the Onyx Cadet 3 large icon is set incorrectly. + // Hopefully at some point this will be fixed. + if (currentCareerStage.RankLargeIcon == "career_rank/CelebrationMoment/219_Cadet_Onyx_III.png") + { + currentCareerStage.RankLargeIcon = "career_rank/CelebrationMoment/19_Cadet_Onyx_III.png"; + } - string qualifiedRankImagePath = Path.Combine(Configuration.AppDataDirectory, "imagecache", currentCareerStage.RankLargeIcon); - string qualifiedAdornmentImagePath = Path.Combine(Configuration.AppDataDirectory, "imagecache", currentCareerStage.RankAdornmentIcon); + string qualifiedRankImagePath = Path.Combine(Configuration.AppDataDirectory, "imagecache", currentCareerStage.RankLargeIcon); + string qualifiedAdornmentImagePath = Path.Combine(Configuration.AppDataDirectory, "imagecache", currentCareerStage.RankAdornmentIcon); - EnsureDirectoryExists(qualifiedRankImagePath); - EnsureDirectoryExists(qualifiedAdornmentImagePath); + EnsureDirectoryExists(qualifiedRankImagePath); + EnsureDirectoryExists(qualifiedAdornmentImagePath); - await DownloadAndSetImage(currentCareerStage.RankLargeIcon, qualifiedRankImagePath, () => HomeViewModel.Instance.RankImage = qualifiedRankImagePath); - await DownloadAndSetImage(currentCareerStage.RankAdornmentIcon, qualifiedAdornmentImagePath, () => HomeViewModel.Instance.AdornmentImage = qualifiedAdornmentImagePath); + await DownloadAndSetImage(currentCareerStage.RankLargeIcon, qualifiedRankImagePath, () => HomeViewModel.Instance.RankImage = qualifiedRankImagePath); + await DownloadAndSetImage(currentCareerStage.RankAdornmentIcon, qualifiedAdornmentImagePath, () => HomeViewModel.Instance.AdornmentImage = qualifiedAdornmentImagePath); + } } - } - else - { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error("Could not get the career snapshot - it's null in the view model."); - return false; - } + else + { + LogEngine.Log("Could not get the career snapshot - it's null in the view model.", LogSeverity.Error); + } + }); } return true; } - catch + catch (Exception ex) { + LogEngine.Log($"An error occurred: {ex.Message}", LogSeverity.Error); return false; } } + internal static void EnsureDirectoryExists(string path) { var file = new FileInfo(path); file.Directory.Create(); } - private static async Task DownloadAndSetImage(string imageName, string imagePath, Action setImageAction) + private static async Task DownloadAndSetImage(string imageName, string imagePath, Action setImageAction = null, bool isOnWaypoint = false) { if (!System.IO.File.Exists(imagePath)) { - var image = await SafeAPICall(async () => await HaloClient.GameCmsGetImage(imageName)); - if (image.Result != null && image.Response.Code == 200) + HaloApiResultContainer image = null; + + if (isOnWaypoint) + { + image = await SafeAPICall(async () => await HaloClient.GameCmsGetGenericWaypointFile(imageName)); + } + else + { + image = await SafeAPICall(async () => await HaloClient.GameCmsGetImage(imageName)); + } + + if (image != null && image.Result != null && image.Response.Code == 200) { await System.IO.File.WriteAllBytesAsync(imagePath, image.Result); } } - await DispatcherWindow.DispatcherQueue.EnqueueAsync(setImageAction); + if (setImageAction != null) + { + await DispatcherWindow.DispatcherQueue.EnqueueAsync(setImageAction); + } } internal static async Task PopulateServiceRecordData() { + await ServiceRecordCancellationTracker.CancelAsync(); + ServiceRecordCancellationTracker = new CancellationTokenSource(); + try { // Get initial service record details var serviceRecordResult = await SafeAPICall(async () => { - return await HaloClient.StatsGetPlayerServiceRecord(HomeViewModel.Instance.Gamertag, LifecycleMode.Matchmade); + return await HaloClient.StatsGetPlayerServiceRecord($"xuid({XboxUserContext.DisplayClaims.Xui[0].XUID})", LifecycleMode.Matchmade); }); - if (serviceRecordResult.Result != null && serviceRecordResult.Response.Code == 200) + if (serviceRecordResult != null && serviceRecordResult.Result != null && serviceRecordResult.Response.Code == 200) { + // First, we want to insert the service record entry in the database. await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => { HomeViewModel.Instance.ServiceRecord = serviceRecordResult.Result; }); DataHandler.InsertServiceRecordEntry(serviceRecordResult.Response.Message); + + await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => + { + RankedViewModel.Instance.RankedLoadingState = MetadataLoadingState.Loading; + RankedViewModel.Instance.Playlists = []; + }); + + // For ranked progression, we can run that on the thread pool to make sure it doesn't block + // all other calls. + _ = Task.Run(async () => + { + await ProcessRankedPlaylists(serviceRecordResult.Result, ServiceRecordCancellationTracker.Token); + }, ServiceRecordCancellationTracker.Token); } return true; @@ -356,6 +408,72 @@ await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => } } + private static async Task ProcessRankedPlaylists(PlayerServiceRecord serviceRecord, CancellationToken token) + { + // Next, we want to also capture the ranked progression. To do that, we will iterate through each + // playlist the player has ever played in, according to the service record. + foreach (var playlist in serviceRecord.Subqueries.PlaylistAssetIds) + { + token.ThrowIfCancellationRequested(); + + // We look inside each playlist configuration to see if the playlist has CSR associated + // with it, since we only care about CSR-enabled playlists to get ranked progression. + var playlistConfigurationResult = await SafeAPICall(async () => + { + return await HaloClient.GameCmsGetMultiplayerPlaylistConfiguration($"{playlist.ToString()}.json"); + }); + + if (playlistConfigurationResult != null && playlistConfigurationResult.Result != null) + { + // Let's check if the playlist has CSR + if (playlistConfigurationResult.Result.HasCsr == true) + { + var playlistCsr = await SafeAPICall(async () => + { + return await HaloClient.SkillGetPlaylistCsr(playlist.ToString(), new List { $"xuid({XboxUserContext.DisplayClaims.Xui[0].XUID})" }); + }); + + // If we successfully got a playlist CSR, let's record that data locally in the database + // and also get additional playlist data. We also check that the Value array has more than + // zero elements, because if there is nothing that means that there is no CSR snapshot + // to capture and store. + if (playlistCsr != null && playlistCsr.Result != null && playlistCsr.Response != null && playlistCsr.Result.Value.Count > 0) + { + DataHandler.InsertPlaylistCSRSnapshot(playlist.ToString(), playlistConfigurationResult.Result.UgcPlaylistVersion.ToString(), playlistCsr.Response.Message); + + var playlistMetadata = await SafeAPICall(async () => + { + return await HaloClient.HIUGCDiscoveryGetPlaylist(playlist.ToString(), playlistConfigurationResult.Result.UgcPlaylistVersion.ToString(), HaloClient.ClearanceToken); + }); + + // Now, let's get the data into the local viewmodel if the metadata acquisition was successful. + if (playlistMetadata != null && playlistMetadata.Result != null) + { + if (!token.IsCancellationRequested) + { + await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => + { + RankedViewModel.Instance.Playlists.Add(new PlaylistCSRSnapshot() + { + Name = playlistMetadata.Result.PublicName, + Id = playlist, + Version = playlistConfigurationResult.Result.UgcPlaylistVersion, + Snapshot = playlistCsr.Result.Value[0], // We only got data for one player. + }); + }); + } + } + } + } + } + } + + await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => + { + RankedViewModel.Instance.RankedLoadingState = MetadataLoadingState.Completed; + }); + } + internal static async Task PopulateDecorationData() { try @@ -375,7 +493,7 @@ internal static async Task PopulateDecorationData() if (backgroundImageResult.Result != null && backgroundImageResult.Response.Code == 200) { // Let's make sure that we create the directory if it does not exist. - FileInfo file = new FileInfo(qualifiedBackgroundImagePath); + FileInfo file = new(qualifiedBackgroundImagePath); file.Directory.Create(); await System.IO.File.WriteAllBytesAsync(qualifiedBackgroundImagePath, backgroundImageResult.Result); @@ -421,7 +539,7 @@ await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => if (emblem.Result != null) { - nameplate = emblemMapping.Result.GetValueOrDefault(emblem.Result.CommonData.Id)?.GetValueOrDefault(customizationResult.Result.Appearance.Emblem.ConfigurationId.ToString()) + nameplate = emblemMapping.Result.GetValueOrDefault(emblem.Result.CommonData.Id)?.GetValueOrDefault(customizationResult.Result.Appearance.Emblem.ConfigurationId.ToString(CultureInfo.InvariantCulture)) ?? new EmblemMapping() { EmblemCmsPath = emblem.Result.CommonData.DisplayPath.Media.MediaUrl.Path, NameplateCmsPath = string.Empty, TextColor = "#FFF" }; } @@ -440,7 +558,8 @@ await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => file = new(qualifiedEmblemImagePath); file.Directory.Create(); file = new(qualifiedBackdropImagePath); file.Directory.Create(); - await DownloadAndSetImage(nameplate.NameplateCmsPath, qualifiedNameplateImagePath, () => HomeViewModel.Instance.Nameplate = qualifiedNameplateImagePath); + // The nameplate image is downloaded from the Waypoint APIs. + await DownloadAndSetImage(nameplate.NameplateCmsPath, qualifiedNameplateImagePath, () => HomeViewModel.Instance.Nameplate = qualifiedNameplateImagePath, true); await DownloadAndSetImage(emblem.Result.CommonData.DisplayPath.Media.MediaUrl.Path, qualifiedEmblemImagePath, () => HomeViewModel.Instance.Emblem = qualifiedEmblemImagePath); await DownloadAndSetImage(backdrop.Result.ImagePath.Media.MediaUrl.Path, qualifiedBackdropImagePath, () => HomeViewModel.Instance.Backdrop = qualifiedBackdropImagePath); @@ -453,7 +572,7 @@ await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => } catch (Exception ex) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error($"Could not populate customization data. {ex.Message}"); + LogEngine.Log($"Could not populate customization data. {ex.Message}", LogSeverity.Error); return false; } } @@ -492,25 +611,25 @@ await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => { - MatchesViewModel.Instance.MatchList = new IncrementalLoadingCollection(); + MatchesViewModel.Instance.MatchList = []; }); return result; } else { - if (SettingsViewModel.Instance.EnableLogging) Logger.Info("No matches to update."); + LogEngine.Log("No matches to update."); } } else { - if (SettingsViewModel.Instance.EnableLogging) Logger.Info("No matches found locally, so need to re-hydrate the database."); + LogEngine.Log("No matches found locally, so need to re-hydrate the database."); var result = await UpdateMatchRecords(distinctMatchIds, MatchLoadingCancellationTracker.Token); await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => { - MatchesViewModel.Instance.MatchList = new IncrementalLoadingCollection(); + MatchesViewModel.Instance.MatchList = []; }); return result; @@ -526,7 +645,7 @@ await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => MatchesViewModel.Instance.MatchLoadingParameter = "0"; }); - if (SettingsViewModel.Instance.EnableLogging) Logger.Error($"Error processing matches. {ex.Message}"); + LogEngine.Log($"Error processing matches. {ex.Message}", LogSeverity.Error); return false; } } @@ -550,55 +669,64 @@ await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => await Parallel.ForEachAsync(matchIds, parallelOptions, async (matchId, token) => { - token.ThrowIfCancellationRequested(); - cancellationToken.ThrowIfCancellationRequested(); + try + { + token.ThrowIfCancellationRequested(); + cancellationToken.ThrowIfCancellationRequested(); - double completionProgress = matchCounter++ / (double)matchesTotal * 100.0; + double completionProgress = matchCounter++ / (double)matchesTotal * 100.0; - await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => - { - MatchesViewModel.Instance.MatchLoadingParameter = $"{matchId} ({matchCounter} out of {matchesTotal} - {completionProgress:#.00}%)"; - }); + await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => + { + MatchesViewModel.Instance.MatchLoadingParameter = $"{matchId} ({matchCounter} out of {matchesTotal} - {completionProgress:#.00}%)"; + }); - var matchStatsAvailability = DataHandler.GetMatchStatsAvailability(matchId.ToString()); + var matchStatsAvailability = DataHandler.GetMatchStatsAvailability(matchId.ToString()); - if (!matchStatsAvailability.MatchAvailable) - { - if (SettingsViewModel.Instance.EnableLogging) Logger.Info($"[{completionProgress:#.00}%] [{matchCounter}/{matchesTotal}] Getting match stats for {matchId}..."); + if (!matchStatsAvailability.MatchAvailable) + { + LogEngine.Log($"[{completionProgress:#.00}%] [{matchCounter}/{matchesTotal}] Getting match stats for {matchId}..."); - var matchStats = await GetMatchStats(matchId.ToString(), completionProgress); - if (matchStats == null) - return; + var matchStats = await GetMatchStats(matchId.ToString(), completionProgress); + if (matchStats == null) + return; - var processedMatchAssetParameters = await DataHandler.UpdateMatchAssetRecords(matchStats.Result); + var processedMatchAssetParameters = await DataHandler.UpdateMatchAssetRecords(matchStats.Result); - bool matchStatsInsertionResult = DataHandler.InsertMatchStats(matchStats.Response.Message); - if (SettingsViewModel.Instance.EnableLogging) Logger.Info(matchStatsInsertionResult - ? $"[{completionProgress:#.00}%] [{matchCounter}/{matchesTotal}] Stored match data for {matchId} in the database." - : $"[{completionProgress:#.00}%] [{matchCounter}/{matchesTotal}] Could not store match {matchId} stats in the database."); - } - else - { - if (SettingsViewModel.Instance.EnableLogging) Logger.Info($"[{completionProgress:#.00}%] [{matchCounter}/{matchesTotal}] Match {matchId} already available. Not requesting new data."); - } + bool matchStatsInsertionResult = DataHandler.InsertMatchStats(matchStats.Response.Message); + LogEngine.Log(matchStatsInsertionResult + ? $"[{completionProgress:#.00}%] [{matchCounter}/{matchesTotal}] Stored match data for {matchId} in the database." + : $"[{completionProgress:#.00}%] [{matchCounter}/{matchesTotal}] Could not store match {matchId} stats in the database."); + } + else + { + LogEngine.Log($"[{completionProgress:#.00}%] [{matchCounter}/{matchesTotal}] Match {matchId} already available. Not requesting new data."); + } - if (!matchStatsAvailability.StatsAvailable) - { - if (SettingsViewModel.Instance.EnableLogging) Logger.Info($"[{completionProgress:#.00}%] [{matchCounter}/{matchesTotal}] Attempting to get player results for players for match {matchId}."); + if (!matchStatsAvailability.StatsAvailable) + { + LogEngine.Log($"[{completionProgress:#.00}%] [{matchCounter}/{matchesTotal}] Attempting to get player results for players for match {matchId}."); - var playerStatsSnapshot = await GetPlayerStats(matchId.ToString()); - if (playerStatsSnapshot == null) - return; + var playerStatsSnapshot = await GetPlayerStats(matchId.ToString()); + if (playerStatsSnapshot == null) + return; - var playerStatsInsertionResult = DataHandler.InsertPlayerMatchStats(matchId.ToString(), playerStatsSnapshot.Response.Message); + var playerStatsInsertionResult = DataHandler.InsertPlayerMatchStats(matchId.ToString(), playerStatsSnapshot.Response.Message); - if (SettingsViewModel.Instance.EnableLogging) Logger.Info(playerStatsInsertionResult - ? $"[{completionProgress:#.00}%] [{matchCounter}/{matchesTotal}] Stored player stats for {matchId}." - : $"[{completionProgress:#.00}%] [{matchCounter}/{matchesTotal}] Could not store player stats for {matchId}."); + LogEngine.Log(playerStatsInsertionResult + ? $"[{completionProgress:#.00}%] [{matchCounter}/{matchesTotal}] Stored player stats for {matchId}." + : $"[{completionProgress:#.00}%] [{matchCounter}/{matchesTotal}] Could not store player stats for {matchId}."); + } + else + { + LogEngine.Log($"[{completionProgress:#.00}%] [{matchCounter}/{matchesTotal}] Match {matchId} player stats already available. Not requesting new data."); + } } - else + catch (Exception ex) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Info($"[{completionProgress:#.00}%] [{matchCounter}/{matchesTotal}] Match {matchId} player stats already available. Not requesting new data."); + // Because processing is parallelized, we don't quite want to error our right away and + // stop processing other matches, so instead we will log an exception locally for investigation. + LogEngine.Log($"Error storing {matchId} at {matchCounter}. {ex.Message}", LogSeverity.Error); } }); @@ -606,7 +734,7 @@ await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => } catch (Exception ex) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error($"Error storing matches. {ex.Message}"); + LogEngine.Log($"Error storing matches. {ex.Message}", LogSeverity.Error); return false; } } @@ -616,7 +744,7 @@ private static async Task await HaloClient.StatsGetMatchStats(matchId)); if (matchStats == null || matchStats.Result == null) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error($"[{completionProgress:#.00}%] [Error] Getting match stats failed for {matchId}."); + LogEngine.Log($"[{completionProgress:#.00}%] [Error] Getting match stats failed for {matchId}.", LogSeverity.Error); return null; } @@ -628,7 +756,7 @@ private static async Task await HaloClient.SkillGetMatchPlayerResult(matchId, targetPlayers!)); if (playerStatsSnapshot == null || playerStatsSnapshot.Result == null || playerStatsSnapshot.Result.Value == null) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error($"Could not obtain player stats for match {matchId}. Requested {targetPlayers.Count} XUIDs."); + LogEngine.Log($"Could not obtain player stats for match {matchId}. Requested {targetPlayers.Count} XUIDs.", LogSeverity.Error); return null; } @@ -647,12 +775,19 @@ private static async Task> GetPlayerMatchIds(string xuid, CancellationToken cancellationToken) { - List matchIds = new List(); + List matchIds = []; int queryStart = 0; var tasks = new ConcurrentBag>>(); - while (true) + bool hitMatchThreshold = false; + int fullyMatchedBatches = 0; + int matchThreshold = 4; + + // If EnableLooseMatchSearch is enabled, we need to also check that the + // threshold for successful matches is not hit. + while (true && + ((SettingsViewModel.Instance.EnableLooseMatchSearch || hitMatchThreshold) && (fullyMatchedBatches < matchThreshold))) { cancellationToken.ThrowIfCancellationRequested(); @@ -660,7 +795,7 @@ private static async Task> GetPlayerMatchIds(string xuid, Cancellatio queryStart += MatchesPerPage; - if (tasks.Count == 8) + if (tasks.Count == 4) { var completedTasks = await Task.WhenAll(tasks); tasks.Clear(); @@ -669,11 +804,20 @@ private static async Task> GetPlayerMatchIds(string xuid, Cancellatio foreach (var batch in completedTasks) { matchIds.AddRange(batch); + + if (SettingsViewModel.Instance.EnableLooseMatchSearch) + { + var matchingMatchesInDb = DataHandler.GetExistingMatchCount(batch); + if (matchingMatchesInDb == batch.Count) + { + fullyMatchedBatches++; + } + } } await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => { - MatchesViewModel.Instance.MatchLoadingParameter = matchIds.Count.ToString(); + MatchesViewModel.Instance.MatchLoadingParameter = matchIds.Count.ToString(CultureInfo.InvariantCulture); }); if (completedTasks.LastOrDefault()?.Count == 0) @@ -684,19 +828,79 @@ await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => } } - if (SettingsViewModel.Instance.EnableLogging) + if ((bool)SettingsViewModel.Instance.EnableLogging) { - Logger.Info($"Ended indexing at {matchIds.Count} total matchmade games."); + LogEngine.Log($"Ended indexing at {matchIds.Count} total matchmade games."); } return matchIds; } + /// + /// Gets the matches asynchronously from the Halo Infinite API. + /// + /// + /// + /// + /// private static async Task> GetMatchBatchAsync(string xuid, int start, int count) { - var matches = await SafeAPICall(() => - HaloClient.StatsGetMatchHistory($"xuid({xuid})", start, count, Den.Dev.Orion.Models.HaloInfinite.MatchType.All)); + List successfulMatches = []; + List<(string xuid, int start, int count)> retryQueue = []; + + var matches = await SafeAPICall(async () => await HaloClient.StatsGetMatchHistory($"xuid({xuid})", start, count, Den.Dev.Orion.Models.HaloInfinite.MatchType.All)); + + if (matches.Response.Code == 200) + { + successfulMatches.AddRange(matches?.Result?.Results?.Select(item => item.MatchId) ?? Enumerable.Empty()); + } + else + { + if ((bool)SettingsViewModel.Instance.EnableLogging) + { + LogEngine.Log($"Error getting match stats through the search endpoint. Adding to retry queue. XUID: {xuid}, START: {start}, COUNT: {count}. Response code: {matches.Response.Code}. Response message: {matches.Response.Message}", LogSeverity.Error); + } + retryQueue.Add((xuid, start, count)); + } - return matches?.Result?.Results?.Select(item => item.MatchId).ToList() ?? new List(); + // Process retry queue after processing successful requests + foreach (var retryRequest in retryQueue) + { + await ProcessRetry(retryRequest, successfulMatches); + } + + return successfulMatches; + } + + private static async Task ProcessRetry((string xuid, int start, int count) retryRequest, List successfulMatches) + { + var retryAttempts = 0; + HaloApiResultContainer retryMatches; + + do + { + retryMatches = await SafeAPICall(async () => await HaloClient.StatsGetMatchHistory($"xuid({retryRequest.xuid})", retryRequest.start, retryRequest.count, Den.Dev.Orion.Models.HaloInfinite.MatchType.All)); + + if (retryMatches.Response.Code == 200) + { + successfulMatches.AddRange(retryMatches?.Result?.Results?.Select(item => item.MatchId) ?? Enumerable.Empty()); + break; // Break the loop if successful + } + else + { + // Log the failure again or handle it appropriately + if ((bool)SettingsViewModel.Instance.EnableLogging) + { + LogEngine.Log($"Error getting match stats through the search endpoint. Retry index: {retryAttempts}. XUID: {retryRequest.xuid}, START: {retryRequest.start}, COUNT: {retryRequest.count}. Response code: {retryMatches.Response.Code}. Response message: {retryMatches.Response.Message}", LogSeverity.Error); + } + retryAttempts++; + } + } while (retryAttempts < 3); // Retry up to 3 times + + if (retryAttempts == 3) + { + // Log or handle the failure after 3 attempts + LogEngine.Log($"Failed to retrieve matches after 3 attempts. XUID: {retryRequest.xuid}, START: {retryRequest.start}, COUNT: {retryRequest.count}", LogSeverity.Error); + } } internal static async Task ReAcquireTokens() @@ -727,23 +931,225 @@ internal static async void GetPlayerMatches() } else { - date = MatchesViewModel.Instance.MatchList.Min(a => a.StartTime).ToString("o", CultureInfo.InvariantCulture); + date = MatchesViewModel.Instance.MatchList.Min(a => a.EndTime).ToString("o", CultureInfo.InvariantCulture); matches = DataHandler.GetMatches($"xuid({HomeViewModel.Instance.Xuid})", date, 10); } if (matches != null) { - var dispatcherWindow = ((Application.Current as App)?.MainWindow) as MainWindow; - await dispatcherWindow.DispatcherQueue.EnqueueAsync(() => + await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => { MatchesViewModel.Instance.MatchList.AddRange(matches); }); } else { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error("Could not get the list of matches for the specified parameters."); + LogEngine.Log("Could not get the list of matches for the specified parameters.", LogSeverity.Error); + } + } + } + + internal static async Task PopulateSeasonCalendar() + { + await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => + { + SeasonCalendarViewModel.Instance.CalendarLoadingState = MetadataLoadingState.Loading; + SeasonCalendarViewModel.Instance.SeasonDays = []; + }); + + // First, we handle the CSR calendar. + var csrCalendar = await SafeAPICall(async () => await HaloClient.GameCmsGetCSRCalendar()); + + // Next, we try to obtain the data for the regular calendar. + + // Using this as a reference point for extra rituals and excluded events. + var settings = SettingsManager.LoadSettings(); + + // First, we get the raw season calendar to get the list of all available events that + // were registered in the Halo Infinite API. + var seasonCalendar = await GetSeasonCalendar(); + if (seasonCalendar != null) + { + if (settings.ExtraRitualEvents != null) + { + foreach (var extraRitualEvent in settings.ExtraRitualEvents) + { + seasonCalendar.Events.Add(new SeasonCalendarEntry { RewardTrackPath = extraRitualEvent }); + } + } + } + + // Once the season calendar is obtained, we want to capture the metadata for every + // single season entry. Events can be populated directly as part of the battle pass query + // down the line. + Dictionary? seasonRewardTracks = await GetSeasonRewardTrackMetadata(seasonCalendar); + + // Then, we get the operations that are available for a given player. This is slightly + // different than the data in the season calendar, so we need both. If no player operations are + // returned, we can abort. + var operations = await GetOperations(); + + if (settings.ExcludedOperations != null) + { + foreach (var excludedOperation in settings.ExcludedOperations.ToList()) + { + var operationToRemove = operations.OperationRewardTracks.FirstOrDefault(x => x.RewardTrackPath == excludedOperation); + if (operationToRemove != null) + { + operations.OperationRewardTracks.Remove(operationToRemove); + } + } + } + + if (csrCalendar != null && csrCalendar.Result != null) + { + for (int i = 0; i < csrCalendar.Result.Seasons.Count; i++) + { + var days = GenerateDateList(csrCalendar.Result.Seasons[i].StartDate.ISO8601Date, csrCalendar.Result.Seasons[i].EndDate.ISO8601Date); + foreach (var day in days) + { + await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => + { + SeasonCalendarViewDayItem calendarItem = new(day, csrCalendar.Result.Seasons[i].CsrSeasonFilePath.Replace(".json", string.Empty), ColorConverter.FromHex(Configuration.SeasonColors[i])); + SeasonCalendarViewModel.Instance.SeasonDays.Add(calendarItem); + }); + } + } + } + else + { + await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => + { + SeasonCalendarViewModel.Instance.CalendarLoadingState = MetadataLoadingState.Completed; + }); + + return false; + } + + // Complete the parsing of individual seasons + for (int i = 0; i < seasonRewardTracks.Count; i++) + { + // Date ranges for season reward tracks are not structured, so we will need to extract them separately. + var rewardTrack = seasonRewardTracks.ElementAt(i); + await ProcessRegularSeasonRanges(rewardTrack.Value.DateRange.Value, rewardTrack.Value.Name.Value, i); + } + + // Then, we process operations + foreach (var operation in operations.OperationRewardTracks) + { + var compoundOperation = new OperationCompoundModel { RewardTrack = operation, SeasonRewardTrack = seasonRewardTracks.GetValueOrDefault(operation.RewardTrackPath) }; + + var isRewardTrackAvailable = DataHandler.IsOperationRewardTrackAvailable(operation.RewardTrackPath); + + if (isRewardTrackAvailable) + { + var operationDetails = DataHandler.GetOperationResponseBody(operation.RewardTrackPath); + if (operationDetails != null) + compoundOperation.RewardTrackMetadata = operationDetails; + + LogEngine.Log($"{operation.RewardTrackPath} (Local) - calendar prep completed"); + } + else + { + var apiResult = await SafeAPICall(async () => await HaloClient.GameCmsGetEvent(operation.RewardTrackPath, HaloClient.ClearanceToken)); + if (apiResult?.Result != null) + DataHandler.UpdateOperationRewardTracks(apiResult.Response.Message, operation.RewardTrackPath); + + compoundOperation.RewardTrackMetadata = apiResult.Result; + + LogEngine.Log($"{operation.RewardTrackPath} - calendar prep completed"); + } + + await ProcessRegularSeasonRanges(compoundOperation.RewardTrackMetadata.DateRange.Value, compoundOperation.RewardTrackMetadata.Name.Value, operations.OperationRewardTracks.IndexOf(operation)); + } + + // And now we check the event data. + var distinctEvents = seasonCalendar.Events.DistinctBy(x => x.RewardTrackPath).ToList(); + foreach (var eventEntry in distinctEvents) + { + var compoundEvent = new OperationCompoundModel + { + RewardTrack = new RewardTrack { RewardTrackPath = eventEntry.RewardTrackPath } + }; + + var isRewardTrackAvailable = DataHandler.IsOperationRewardTrackAvailable(eventEntry.RewardTrackPath); + + if (isRewardTrackAvailable) + { + compoundEvent.RewardTrackMetadata = DataHandler.GetOperationResponseBody(eventEntry.RewardTrackPath); + + var rewardTrack = await GetRewardTrackMetadata("event", compoundEvent.RewardTrackMetadata.TrackId); + + LogEngine.Log($"{eventEntry.RewardTrackPath} (Local) - calendar prep completed"); + } + else + { + var apiResult = await SafeAPICall(async () => await HaloClient.GameCmsGetEvent(eventEntry.RewardTrackPath, HaloClient.ClearanceToken)); + if (apiResult?.Result != null) + { + DataHandler.UpdateOperationRewardTracks(apiResult.Response.Message, eventEntry.RewardTrackPath); + } + compoundEvent.RewardTrackMetadata = apiResult.Result; + + LogEngine.Log($"{eventEntry.RewardTrackPath} - calendar prep completed"); } + + await ProcessRegularSeasonRanges(compoundEvent.RewardTrackMetadata.DateRange.Value, compoundEvent.RewardTrackMetadata.Name.Value, distinctEvents.IndexOf(eventEntry)); } + + await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => + { + SeasonCalendarViewModel.Instance.CalendarLoadingState = MetadataLoadingState.Completed; + }); + + return true; + } + + private async static Task ProcessRegularSeasonRanges(string rangeText, string name, int index) + { + List> ranges = DateRangeParser.ExtractDateRanges(rangeText); + foreach (var range in ranges) + { + var days = GenerateDateList(range.Item1, range.Item2); + if (days != null) + { + foreach (var day in days) + { + await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => + { + var targetDay = SeasonCalendarViewModel.Instance.SeasonDays + .Where(x => x.DateTime.Date == day.Date) + .FirstOrDefault(); + + if (targetDay != null) + { + targetDay.RegularSeasonText = name; + targetDay.RegularSeasonMarkerColor = ColorConverter.FromHex(Configuration.SeasonColors[index]); + } + else + { + SeasonCalendarViewDayItem calendarItem = new(day, string.Empty, new Microsoft.UI.Xaml.Media.SolidColorBrush(Colors.White)); + calendarItem.RegularSeasonText = name; + calendarItem.RegularSeasonMarkerColor = ColorConverter.FromHex(Configuration.SeasonColors[index]); + SeasonCalendarViewModel.Instance.SeasonDays.Add(calendarItem); + } + }); + } + } + } + } + + static List GenerateDateList(DateTime? lowerDate, DateTime? upperDate) + { + List dateList = []; + + // Iterate through the dates and add them to the list + for (DateTime date = (DateTime)lowerDate; date <= upperDate; date = date.AddDays(1)) + { + dateList.Add(date); + } + + return dateList; } internal static async void PopulateMedalMatchData(long medalNameId) @@ -759,40 +1165,38 @@ internal static async void PopulateMedalMatchData(long medalNameId) } else { - date = MedalMatchesViewModel.Instance.MatchList.Min(a => a.StartTime).ToString("o", CultureInfo.InvariantCulture); + date = MedalMatchesViewModel.Instance.MatchList.Min(a => a.EndTime).ToString("o", CultureInfo.InvariantCulture); matches = DataHandler.GetMatchesWithMedal($"xuid({HomeViewModel.Instance.Xuid})", medalNameId, date, 10); } if (matches != null) { - var dispatcherWindow = ((Application.Current as App)?.MainWindow) as MainWindow; - await dispatcherWindow.DispatcherQueue.EnqueueAsync(() => + await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => { MedalMatchesViewModel.Instance.MatchList.AddRange(matches); }); } else { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error("Could not get the list of matches for the specified parameters."); + LogEngine.Log("Could not get the list of matches for the specified parameters.", LogSeverity.Error); } } } - internal static List? EnrichMedalMetadata(List medals) + internal static List? EnrichMedalMetadata(List medals, [CallerMemberName] string caller = null) { try { - if (SettingsViewModel.Instance.EnableLogging) - Logger.Info("Getting medal metadata..."); + LogEngine.Log($"Enriching medal metadata on behalf of {caller}..."); - if (MedalMetadata?.Result?.Medals == null || MedalMetadata.Result.Medals.Count == 0) + if (MedalMetadata == null || MedalMetadata.Medals == null || MedalMetadata.Medals.Count == 0) return null; var richMedals = medals - .Where(medal => MedalMetadata.Result.Medals.Any(metaMedal => metaMedal.NameId == medal.NameId)) + .Where(medal => MedalMetadata.Medals.Any(metaMedal => metaMedal.NameId == medal.NameId)) .Select(medal => { - var metaMedal = MedalMetadata.Result.Medals.First(c => c.NameId == medal.NameId); + var metaMedal = MedalMetadata.Medals.First(c => c.NameId == medal.NameId); medal.Name = metaMedal.Name; medal.Description = metaMedal.Description; medal.DifficultyIndex = metaMedal.DifficultyIndex; @@ -807,29 +1211,27 @@ await dispatcherWindow.DispatcherQueue.EnqueueAsync(() => } catch (Exception ex) { - if (SettingsViewModel.Instance.EnableLogging) - Logger.Error($"Could not enrich medal metadata. Error: {ex.Message}"); + LogEngine.Log($"Could not enrich medal metadata. Error: {ex.Message}", LogSeverity.Error); return null; } } - - internal static async Task PopulateMedalData() { try { - if (SettingsViewModel.Instance.EnableLogging) Logger.Info("Getting medal metadata..."); + LogEngine.Log("Getting medal metadata..."); - if (MedalMetadata?.Result?.Medals == null || MedalMetadata.Result.Medals.Count == 0) + if (MedalMetadata == null || MedalMetadata.Medals == null || MedalMetadata.Medals.Count == 0) return false; + // This gets the medals that are locally stored. var medals = DataHandler.GetMedals(); if (medals == null) return false; - var compoundMedals = medals.Join(MedalMetadata.Result.Medals, earned => earned.NameId, references => references.NameId, (earned, references) => new Medal() + var compoundMedals = medals.Join(MedalMetadata.Medals, earned => earned.NameId, references => references.NameId, (earned, references) => new Medal() { Count = earned.Count, Description = references.Description, @@ -852,7 +1254,7 @@ await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => string qualifiedMedalPath = Path.Combine(Configuration.AppDataDirectory, "imagecache", "medals"); - var spriteRequestResult = await SafeAPICall(async () => await HaloClient.GameCmsGetGenericWaypointFile(MedalMetadata.Result.Sprites.ExtraLarge.Path)); + var spriteRequestResult = await SafeAPICall(async () => await HaloClient.GameCmsGetGenericWaypointFile(MedalMetadata.Sprites.ExtraLarge.Path)); var spriteContent = spriteRequestResult?.Result; if (spriteContent != null) @@ -861,7 +1263,10 @@ await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => SkiaSharp.SKBitmap bmp = SkiaSharp.SKBitmap.Decode(ms); using var pixmap = bmp.PeekPixels(); - foreach (var medal in compoundMedals) + // We want to download all medals that are available + // in the stack. That way, we don't have to fiddle with + // individual missing medals later on. + foreach (var medal in MedalMetadata.Medals) { string medalImagePath = Path.Combine(qualifiedMedalPath, $"{medal.NameId}.png"); EnsureDirectoryExists(medalImagePath); @@ -875,21 +1280,29 @@ await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => var subset = pixmap.ExtractSubset(rectI); using var data = subset.Encode(SkiaSharp.SKPngEncoderOptions.Default); await System.IO.File.WriteAllBytesAsync(medalImagePath, data.ToArray()); - if (SettingsViewModel.Instance.EnableLogging) Logger.Info($"Wrote medal to file: {medalImagePath}"); + LogEngine.Log($"Wrote medal to file: {medalImagePath}"); } } - if (SettingsViewModel.Instance.EnableLogging) Logger.Info("Got medals."); + LogEngine.Log("Got medals."); } } catch (Exception ex) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error($"Could not obtain medal metadata. Error: {ex.Message}"); + LogEngine.Log($"Could not obtain medal metadata. Error: {ex.Message}", LogSeverity.Error); return false; } return true; } + public static async Task GetRewardTrackMetadata(string eventType, string trackId) + { + return (await SafeAPICall(async () => + { + return await HaloClient.EconomyGetRewardTrack($"xuid({XboxUserContext.DisplayClaims.Xui[0].XUID})", eventType, $"{trackId}"); + })).Result; + } + public static async Task GetOperations() { return (await SafeAPICall(async () => @@ -898,6 +1311,14 @@ await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => })).Result; } + public static async Task GetSeasonCalendar() + { + return (await SafeAPICall(async () => + { + return await HaloClient.GameCmsGetSeasonCalendar(); + })).Result; + } + public static async Task GetInGameCurrency(string currencyId) { return (await SafeAPICall(async () => @@ -910,15 +1331,58 @@ public static async Task PopulateBattlePassData(CancellationToken cancella { await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => BattlePassViewModel.Instance.BattlePassLoadingState = MetadataLoadingState.Loading); + // Using this as a reference point for extra rituals and excluded events. + var settings = SettingsManager.LoadSettings(); + + // First, we get the raw season calendar to get the list of all available events that + // were registered in the Halo Infinite API. + var seasonCalendar = await GetSeasonCalendar(); + if (seasonCalendar != null) + { + if (settings.ExtraRitualEvents != null) + { + foreach (var extraRitualEvent in settings.ExtraRitualEvents) + { + seasonCalendar.Events.Add(new SeasonCalendarEntry { RewardTrackPath = extraRitualEvent }); + } + } + } + + // Once the season calendar is obtained, we want to capture the metadata for every + // single season entry. Events can be populated directly as part of the battle pass query + // down the line. + Dictionary? seasonRewardTracks = await GetSeasonRewardTrackMetadata(seasonCalendar); + + // Then, we get the operations that are available for a given player. This is slightly + // different than the data in the season calendar, so we need both. If no player operations are + // returned, we can abort. var operations = await GetOperations(); if (operations == null) return false; + if (settings.ExcludedOperations != null) + { + foreach (var excludedOperation in settings.ExcludedOperations.ToList()) + { + var operationToRemove = operations.OperationRewardTracks.FirstOrDefault(x => x.RewardTrackPath == excludedOperation); + if (operationToRemove != null) + { + operations.OperationRewardTracks.Remove(operationToRemove); + } + } + } + + // Let's get the data for each of the operations. foreach (var operation in operations.OperationRewardTracks) { + // Tell the user that the operations are currently being loaded by changing the + // loading parameter to the reward track path. cancellationToken.ThrowIfCancellationRequested(); await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => BattlePassViewModel.Instance.BattlePassLoadingParameter = operation.RewardTrackPath); - var compoundEvent = new OperationCompoundModel { RewardTrack = operation }; + // We can now also pull the metadata from the previously declared + // calendar container. + var compoundOperation = new OperationCompoundModel { RewardTrack = operation, SeasonRewardTrack = seasonRewardTracks.GetValueOrDefault(operation.RewardTrackPath) }; + var isRewardTrackAvailable = DataHandler.IsOperationRewardTrackAvailable(operation.RewardTrackPath); if (isRewardTrackAvailable) @@ -926,10 +1390,10 @@ public static async Task PopulateBattlePassData(CancellationToken cancella var operationDetails = DataHandler.GetOperationResponseBody(operation.RewardTrackPath); if (operationDetails != null) { - compoundEvent.RewardTrackMetadata = operationDetails; + compoundOperation.RewardTrackMetadata = operationDetails; } - compoundEvent.Rewards = new(await GetFlattenedRewards(operationDetails.Ranks, operation.CurrentProgress.Rank)); - if (SettingsViewModel.Instance.EnableLogging) Logger.Info($"{operation.RewardTrackPath} (Local) - Completed"); + compoundOperation.Rewards = new(await GetFlattenedRewards(operationDetails.Ranks, operation.CurrentProgress.Rank)); + LogEngine.Log($"{operation.RewardTrackPath} (Local) - Completed"); } else { @@ -938,17 +1402,119 @@ public static async Task PopulateBattlePassData(CancellationToken cancella { DataHandler.UpdateOperationRewardTracks(apiResult.Response.Message, operation.RewardTrackPath); } + compoundOperation.RewardTrackMetadata = apiResult.Result; + compoundOperation.Rewards = new(await GetFlattenedRewards(apiResult.Result.Ranks, operation.CurrentProgress.Rank)); + LogEngine.Log($"{operation.RewardTrackPath} - Completed"); + } + + await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => BattlePassViewModel.Instance.BattlePasses.Add(compoundOperation)); + } + + // Remember that in earlier versions of events they were chunked up - you had to + // play the same event over many weeks (e.g., they would enable you to play 10 levels per week). + // For the purposes of this experience, we can just select the distinct events based on reward + // paths (they are are the same, regardless of which which it happ + foreach (var eventEntry in seasonCalendar.Events.DistinctBy(x => x.RewardTrackPath)) + { + // Tell the user that the operations are currently being loaded by changing the + // loading parameter to the reward track path. + cancellationToken.ThrowIfCancellationRequested(); + await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => BattlePassViewModel.Instance.BattlePassLoadingParameter = eventEntry.RewardTrackPath); + + OperationCompoundModel compoundEvent = new() + { + RewardTrack = new RewardTrack() { RewardTrackPath = eventEntry.RewardTrackPath } + }; + + var isRewardTrackAvailable = DataHandler.IsOperationRewardTrackAvailable(eventEntry.RewardTrackPath); + + if (isRewardTrackAvailable) + { + var eventDetails = DataHandler.GetOperationResponseBody(eventEntry.RewardTrackPath); + if (eventDetails != null) + { + compoundEvent.RewardTrackMetadata = eventDetails; + } + + // For events, there is no "Current Progress" indicator the same way we have it for operations, so + // we're using a dummy value of -1. + + // We want to get the current progress for the evnet. + var rewardTrack = await GetRewardTrackMetadata("event", compoundEvent.RewardTrackMetadata.TrackId); + + compoundEvent.Rewards = new(await GetFlattenedRewards(eventDetails.Ranks, (rewardTrack != null ? rewardTrack.CurrentProgress.Rank : -1))); + LogEngine.Log($"{eventEntry.RewardTrackPath} (Local) - Completed"); + } + else + { + var apiResult = await SafeAPICall(async () => await HaloClient.GameCmsGetEvent(eventEntry.RewardTrackPath, HaloClient.ClearanceToken)); + if (apiResult?.Result != null) + { + DataHandler.UpdateOperationRewardTracks(apiResult.Response.Message, eventEntry.RewardTrackPath); + } compoundEvent.RewardTrackMetadata = apiResult.Result; - compoundEvent.Rewards = new(await GetFlattenedRewards(apiResult.Result.Ranks, operation.CurrentProgress.Rank)); - if (SettingsViewModel.Instance.EnableLogging) Logger.Info($"{operation.RewardTrackPath} - Completed"); + compoundEvent.Rewards = new(await GetFlattenedRewards(apiResult.Result.Ranks, -1)); + LogEngine.Log($"{eventEntry.RewardTrackPath} - Completed"); } - await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => BattlePassViewModel.Instance.BattlePasses.Add(compoundEvent)); + // Let's make sure that we also download the image for the event, if available. + if (!string.IsNullOrWhiteSpace(compoundEvent.RewardTrackMetadata.SummaryImagePath)) + { + // Some images, like in the example of Noble Intentions event, do not end with an extension. This is not + // at all a common occurrence, so I am just making sure that I check it ahead of time in this one special + // instance. + if (!compoundEvent.RewardTrackMetadata.SummaryImagePath.EndsWith(".png", StringComparison.InvariantCultureIgnoreCase) + && !compoundEvent.RewardTrackMetadata.SummaryImagePath.EndsWith(".jpg", StringComparison.InvariantCultureIgnoreCase) + && !compoundEvent.RewardTrackMetadata.SummaryImagePath.EndsWith(".jpeg", StringComparison.InvariantCultureIgnoreCase)) + { + compoundEvent.RewardTrackMetadata.SummaryImagePath += ".png"; + } + + await UpdateLocalImage("imagecache", compoundEvent.RewardTrackMetadata.SummaryImagePath); + } + + await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => BattlePassViewModel.Instance.Events.Add(compoundEvent)); } return true; } + private static async Task?> GetSeasonRewardTrackMetadata(SeasonCalendar? seasonCalendar) + { + if (seasonCalendar == null || seasonCalendar.Seasons == null || seasonCalendar.Seasons.Count == 0) + return null; + + var seasonRewardTracks = new Dictionary(); + + foreach (var season in seasonCalendar.Seasons) + { + if (string.IsNullOrWhiteSpace(season.SeasonMetadata) || string.IsNullOrWhiteSpace(season.OperationTrackPath)) + continue; + + var result = await SafeAPICall(async () => + await HaloClient.GameCmsGetSeasonRewardTrack(season.SeasonMetadata, HaloClient.ClearanceToken) + ); + + if (result?.Result != null) + { + seasonRewardTracks.Add(season.OperationTrackPath, result.Result); + + // If we have the metadata, let's also make sure that we download the relevant images. + await UpdateLocalImage("imagecache", result.Result.SummaryBackgroundPath); + await UpdateLocalImage("imagecache", result.Result.BattlePassSeasonUpsellBackgroundImage); + await UpdateLocalImage("imagecache", result.Result.ChallengesBackgroundPath); + await UpdateLocalImage("imagecache", result.Result.BattlePassLogoImage); + await UpdateLocalImage("imagecache", result.Result.SeasonLogoImage); + await UpdateLocalImage("imagecache", result.Result.RitualLogoImage); + await UpdateLocalImage("imagecache", result.Result.StorefrontBackgroundImage); + await UpdateLocalImage("imagecache", result.Result.CardBackgroundImage); + await UpdateLocalImage("imagecache", result.Result.ProgressionBackgroundImage); + } + } + + return seasonRewardTracks.Count > 0 ? seasonRewardTracks : null; + } + internal static async Task PopulateUserInventory() { var result = await SafeAPICall(async () => @@ -964,9 +1530,9 @@ internal static async Task PopulateUserInventory() return false; } - internal static async Task>> GetFlattenedRewards(List rankSnapshots, int currentPlayerRank) + internal static async Task>> GetFlattenedRewards(List rankSnapshots, int currentPlayerRank) { - List rewards = []; + List rewards = []; foreach (var rewardBucket in rankSnapshots) { @@ -981,29 +1547,48 @@ internal static async Task>> Get rewards.AddRange(paidInventoryRewards); rewards.AddRange(paidCurrencyRewards); - if (SettingsViewModel.Instance.EnableLogging) Logger.Info($"Rank {rewardBucket.Rank} - Completed"); + LogEngine.Log($"Rank {rewardBucket.Rank} - Completed"); } return rewards.GroupBy(x => x.Ranks.Item1); } - internal static async Task> ExtractCurrencyRewards(int rank, int playerRank, IEnumerable currencyItems, bool isFree) + internal static async Task> ExtractCurrencyRewards(int rank, int playerRank, IEnumerable currencyItems, bool isFree) { - List rewardContainers = new(); + List rewardContainers = new(); foreach (var currencyReward in currencyItems) { - RewardMetaContainer container = new() + ItemMetadataContainer container = new() { Ranks = new Tuple(rank, playerRank), IsFree = isFree, - Amount = currencyReward.Amount, + ItemValue = currencyReward.Amount, CurrencyDetails = await GetInGameCurrency(currencyReward.CurrencyPath) }; if (container.CurrencyDetails != null) { - string currencyImageLocation = GetCurrencyImageLocation(container.CurrencyDetails.Id.ToLower(CultureInfo.InvariantCulture)); + switch (container.CurrencyDetails.Id.ToLower(CultureInfo.InvariantCulture)) + { + case "rerollcurrency": + container.Type = ItemClass.ChallengeReroll; + break; + case "xpgrant": + container.Type = ItemClass.XPGrant; + break; + case "xb": + container.Type = ItemClass.XPBoost; + break; + case "cr": + container.Type = ItemClass.Credits; + break; + case "softcurrency": + container.Type = ItemClass.SpartanPoints; + break; + } + + string currencyImageLocation = GetCurrencyImageLocation(container.Type); container.ImagePath = currencyImageLocation; @@ -1025,13 +1610,15 @@ internal static async Task> ExtractCurrencyRewards(int return rewardContainers; } - private static string GetCurrencyImageLocation(string currencyId) + private static string GetCurrencyImageLocation(ItemClass type) { - return currencyId switch + return type switch { - "rerollcurrency" => "progression/Currencies/1104-000-data-pad-e39bef84-2x2.png", - "xb" => "progression/Currencies/1103-000-xp-boost-5e92621a-2x2.png", - "cr" => "progression/Currencies/Credit_Coin-SM.png", + ItemClass.ChallengeReroll => "progression/Currencies/1104-000-data-pad-e39bef84-2x2.png", // Challenge swap + ItemClass.XPGrant => "progression/Currencies/1102-000-xp-grant-c77c6396-2x2.png", // XP grant + ItemClass.XPBoost => "progression/Currencies/1103-000-xp-boost-5e92621a-2x2.png", // XP boost + ItemClass.Credits => "progression/Currencies/Credit_Coin-SM.png", // Credit coins + ItemClass.SpartanPoints => "progression/StoreContent/ToggleTiles/SpartanPoints_Common_2x2.png", // Spartan points _ => string.Empty, }; } @@ -1039,74 +1626,76 @@ private static string GetCurrencyImageLocation(string currencyId) private static async Task WriteImageToFileAsync(string path, byte[] imageData) { await System.IO.File.WriteAllBytesAsync(path, imageData); - if (SettingsViewModel.Instance.EnableLogging) Logger.Info("Stored local image: " + path); + LogEngine.Log("Stored local image: " + path); } - internal static async Task> ExtractInventoryRewards(int rank, int playerRank, IEnumerable inventoryItems, bool isFree) + internal static async Task> ExtractInventoryRewards(int rank, int playerRank, IEnumerable inventoryItems, bool isFree) { - List rewardContainers = []; + List rewardContainers = new(inventoryItems.Count()); + SemaphoreSlim semaphore = new(Environment.ProcessorCount); - await Task.WhenAll(inventoryItems.Select(async inventoryReward => + async Task ProcessInventoryItem(InventoryAmount inventoryReward) { - bool inventoryItemLocallyAvailable = DataHandler.IsInventoryItemAvailable(inventoryReward.InventoryItemPath); + await semaphore.WaitAsync().ConfigureAwait(false); - var container = new RewardMetaContainer + try { - Ranks = Tuple.Create(rank, playerRank), - IsFree = isFree, - Amount = inventoryReward.Amount, - }; + bool inventoryItemLocallyAvailable = DataHandler.IsInventoryItemAvailable(inventoryReward.InventoryItemPath); - if (inventoryItemLocallyAvailable) - { - container.ItemDetails = DataHandler.GetInventoryItem(inventoryReward.InventoryItemPath); + var container = new ItemMetadataContainer + { + Ranks = Tuple.Create(rank, playerRank), + IsFree = isFree, + ItemValue = inventoryReward.Amount, + Type = ItemClass.StandardReward, + }; - if (container.ItemDetails != null) + if (inventoryItemLocallyAvailable) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Info($"Trying to get local image for {container.ItemDetails.CommonData.Id} (entity: {inventoryReward.InventoryItemPath})"); + container.ItemDetails = DataHandler.GetInventoryItem(inventoryReward.InventoryItemPath); - if (await UpdateLocalImage("imagecache", container.ItemDetails.CommonData.DisplayPath.Media.MediaUrl.Path)) + if (container.ItemDetails != null) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Info($"Stored local image: {container.ItemDetails.CommonData.DisplayPath.Media.MediaUrl.Path}"); + LogEngine.Log($"Trying to get local image for {container.ItemDetails.CommonData.Id} (entity: {inventoryReward.InventoryItemPath})"); + + if (await UpdateLocalImage("imagecache", container.ItemDetails.CommonData.DisplayPath.Media.MediaUrl.Path).ConfigureAwait(false)) + { + LogEngine.Log($"Stored local image: {container.ItemDetails.CommonData.DisplayPath.Media.MediaUrl.Path}"); + } + else + { + LogEngine.Log(container.ItemDetails.CommonData.DisplayPath.Media.MediaUrl.Path, LogSeverity.Error); + } } else { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error($"Failed to store local image: {container.ItemDetails.CommonData.DisplayPath.Media.MediaUrl.Path}"); + LogEngine.Log("Inventory item is null.", LogSeverity.Error); } } else { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error("Inventory item is null."); - } - } - else - { - var item = await SafeAPICall(async () => - await HaloClient.GameCmsGetItem(inventoryReward.InventoryItemPath, HaloClient.ClearanceToken) - ); - - if (item != null && item.Result != null) - { - if (SettingsViewModel.Instance.EnableLogging) Logger.Info($"Trying to get local image for {item.Result.CommonData.Id} (entity: {inventoryReward.InventoryItemPath})"); + var item = await SafeAPICall(async () => await HaloClient.GameCmsGetItem(inventoryReward.InventoryItemPath, HaloClient.ClearanceToken).ConfigureAwait(false)).ConfigureAwait(false); - if (await UpdateLocalImage("imagecache", item.Result.CommonData.DisplayPath.Media.MediaUrl.Path)) - { - if (SettingsViewModel.Instance.EnableLogging) Logger.Info($"Stored local image: {item.Result.CommonData.DisplayPath.Media.MediaUrl.Path}"); - } - else + if (item != null && item.Result != null) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error($"Failed to store local image: {item.Result.CommonData.DisplayPath.Media.MediaUrl.Path}"); - } + LogEngine.Log($"Trying to get local image for {item.Result.CommonData.Id} (entity: {inventoryReward.InventoryItemPath})"); - DataHandler.UpdateInventoryItems(item.Response.Message, inventoryReward.InventoryItemPath); - container.ItemDetails = item.Result; + if (await UpdateLocalImage("imagecache", item.Result.CommonData.DisplayPath.Media.MediaUrl.Path).ConfigureAwait(false)) + { + LogEngine.Log($"Stored local image: {item.Result.CommonData.DisplayPath.Media.MediaUrl.Path}"); + } + else + { + LogEngine.Log(item.Result.CommonData.DisplayPath.Media.MediaUrl.Path, LogSeverity.Error); + } + + DataHandler.UpdateInventoryItems(item.Response.Message, inventoryReward.InventoryItemPath); + container.ItemDetails = item.Result; + } } - } - container.ImagePath = container.ItemDetails?.CommonData.DisplayPath.Media.MediaUrl.Path; + container.ImagePath = container.ItemDetails?.CommonData.DisplayPath.Media.MediaUrl.Path; - try - { lock (rewardContainers) { rewardContainers.Add(container); @@ -1114,17 +1703,23 @@ await HaloClient.GameCmsGetItem(inventoryReward.InventoryItemPath, HaloClient.Cl } catch (Exception ex) { - - if (SettingsViewModel.Instance.EnableLogging) Logger.Error($"Could not set container item details for {inventoryReward.InventoryItemPath}. {ex.Message}"); + LogEngine.Log($"Could not set container item details for {inventoryReward.InventoryItemPath}. {ex.Message}", LogSeverity.Error); + } + finally + { + semaphore.Release(); } - })); + } + + await Task.WhenAll(inventoryItems.Select(ProcessInventoryItem)).ConfigureAwait(false); return rewardContainers; } - internal static async Task UpdateLocalImage(string subDirectoryName, string imagePath) { + if (string.IsNullOrWhiteSpace(imagePath)) + return false; string qualifiedImagePath = Path.Join(Configuration.AppDataDirectory, subDirectoryName, imagePath); @@ -1166,13 +1761,13 @@ internal static async Task PopulateCsrImages() { try { - if (SettingsViewModel.Instance.EnableLogging) Logger.Info($"Downloading image for {rank}..."); + LogEngine.Log($"Downloading image for {rank}..."); byte[] imageBytes = await WorkshopHttpClient.GetByteArrayAsync(new Uri($"{Configuration.HaloWaypointCsrImageEndpoint}/{rank}.png")); await System.IO.File.WriteAllBytesAsync(qualifiedRankImagePath, imageBytes); } catch (Exception ex) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Info($"Could not download and store rank image for {rank}. {ex.Message}"); + LogEngine.Log($"Could not download and store rank image for {rank}. {ex.Message}", LogSeverity.Error); } } } @@ -1180,6 +1775,124 @@ internal static async Task PopulateCsrImages() return true; } + internal static async Task PopulateExchangeData() + { + try + { + await ExchangeCancellationTracker.CancelAsync(); + ExchangeCancellationTracker = new CancellationTokenSource(); + + await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => + { + ExchangeViewModel.Instance.ExchangeItems = new ObservableCollection(); + ExchangeViewModel.Instance.ExchangeLoadingState = MetadataLoadingState.Loading; + }); + + var exchangeOfferings = await SafeAPICall(async () => + { + return await HaloClient.EconomyGetSoftCurrencyStore($"xuid({XboxUserContext.DisplayClaims.Xui[0].XUID})"); + }); + + if (exchangeOfferings != null && exchangeOfferings.Result != null) + { + _ = Task.Run(async () => + { + await ProcessExchangeItems(exchangeOfferings.Result, ExchangeCancellationTracker.Token); + }, ExchangeCancellationTracker.Token); + + return true; + } + return false; + } + catch (Exception ex) + { + LogEngine.Log($"Failed to finish updating The Exchange content. Reason: {ex.Message}"); + + return false; + } + } + + private static async Task ProcessExchangeItems(StoreItem exchangeStoreItem, CancellationToken token) + { + foreach (var offering in exchangeStoreItem.Offerings) + { + token.ThrowIfCancellationRequested(); + + // We're only interested in offerings that have items attached to them. + // Other items are not relevant, and we can skip them (there are no currency + // or seasonal offers attached to Exchange items. + if (offering != null && offering.IncludedItems.Any()) + { + // Current Exchange offering can contain more items in one (e.g., logos) + // but ultimately maps to just one item. + var item = offering.IncludedItems.FirstOrDefault(); + + await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => + { + ExchangeViewModel.Instance.ExpirationDate = exchangeStoreItem.StorefrontExpirationDate; + }); + + if (item != null) + { + var itemMetadata = await SafeAPICall(async () => + { + return await HaloClient.GameCmsGetItem(item.ItemPath, HaloClient.ClearanceToken); + }); + + if (itemMetadata != null) + { + string folderPath = !string.IsNullOrWhiteSpace(itemMetadata.Result.CommonData.DisplayPath.FolderPath) ? itemMetadata.Result.CommonData.DisplayPath.FolderPath : itemMetadata.Result.CommonData.DisplayPath.Media.FolderPath; + string fileName = !string.IsNullOrWhiteSpace(itemMetadata.Result.CommonData.DisplayPath.FileName) ? itemMetadata.Result.CommonData.DisplayPath.FileName : itemMetadata.Result.CommonData.DisplayPath.Media.FileName; + + var metadataContainer = new ItemMetadataContainer + { + ItemType = item.ItemType, + // There is usually just one price, since it's just one offering. There may be + // several included items (e.g., shoulder pads) but the price should still be the + // same regardless, at least from the current Exchange implementation. + // If for some reason there is no price assigned, we will default to -1. + ItemValue = (offering.Prices != null && offering.Prices.Any()) ? offering.Prices[0].Cost : -1, + ImagePath = (!string.IsNullOrWhiteSpace(folderPath) && !string.IsNullOrWhiteSpace(fileName)) ? Path.Combine(folderPath, fileName).Replace("\\", "/") : itemMetadata.Result.CommonData.DisplayPath.Media.MediaUrl.Path, + ItemDetails = new InGameItem() + { + CommonData = itemMetadata.Result.CommonData, + }, + }; + + if (Path.IsPathRooted(metadataContainer.ImagePath)) + { + metadataContainer.ImagePath = metadataContainer.ImagePath.TrimStart(Path.DirectorySeparatorChar); + metadataContainer.ImagePath = metadataContainer.ImagePath.TrimStart(Path.AltDirectorySeparatorChar); + } + + string qualifiedItemImagePath = Path.Combine(Configuration.AppDataDirectory, "imagecache", metadataContainer.ImagePath); + + EnsureDirectoryExists(qualifiedItemImagePath); + + await DownloadAndSetImage(metadataContainer.ImagePath, qualifiedItemImagePath); + + if (!token.IsCancellationRequested) + { + await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => + { + ExchangeViewModel.Instance.ExchangeItems.Add(metadataContainer); + }); + } + + LogEngine.Log($"Got item for Exchange listing: {item.ItemPath}"); + } + } + } + } + + await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => + { + ExchangeViewModel.Instance.ExchangeLoadingState = MetadataLoadingState.Completed; + }); + + return true; + } + internal static async Task InitializeAllDataOnLaunch() { var authResult = await InitializePublicClientApplication(); @@ -1194,6 +1907,11 @@ await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => if (instantiationResult) { + if (string.IsNullOrWhiteSpace(HaloClient.ClearanceToken)) + { + LogEngine.Log($"The clearance is empty, so many API calls that depend on it may fail."); + } + HomeViewModel.Instance.Gamertag = XboxUserContext.DisplayClaims.Xui[0].Gamertag; HomeViewModel.Instance.Xuid = XboxUserContext.DisplayClaims.Xui[0].XUID; @@ -1202,11 +1920,11 @@ await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => if (journalingMode.Equals("wal", StringComparison.Ordinal)) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Info("Successfully set WAL journaling mode."); + LogEngine.Log("Successfully set WAL journaling mode."); } else { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error("Could not set WAL journaling mode."); + LogEngine.Log("Could not set WAL journaling mode.", LogSeverity.Warning); } await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => @@ -1218,16 +1936,33 @@ await DispatcherWindow.DispatcherQueue.EnqueueAsync(() => }); // We want to populate the medal metadata before we do anything else. - _ = await PrepopulateMedalMetadata(); + MedalMetadata = await PrepopulateMedalMetadata(); - Parallel.Invoke( + // Let's get career data first to make sure that it's quickly populated. + _ = await PopulateCareerData(); + + // Service Record data should be pulled early to make sure that we + // get the latest medals quickly before everything else is populated. + _ = await PopulateServiceRecordData(); + + Parallel.Invoke( async () => await PopulateMedalData(), + async () => await PopulateExchangeData(), async () => await PopulateCsrImages(), - async () => await PopulateServiceRecordData(), - async () => await PopulateCareerData(), + async () => + { + try + { + await PopulateSeasonCalendar(); + } + catch (Exception ex) + { + LogEngine.Log($"Could not populate the calendar. {ex.Message}", LogSeverity.Error); + } + }, async () => await PopulateUserInventory(), async () => await PopulateCustomizationData(), - async () => await PopulateDecorationData(), + async () => await PopulateDecorationData(), async () => { var matchRecordsOutcome = await PopulateMatchRecordsData(); diff --git a/src/OpenSpartan.Workshop/Core/WorkshopLogger.cs b/src/OpenSpartan.Workshop/Core/WorkshopLogger.cs new file mode 100644 index 0000000..1411bdc --- /dev/null +++ b/src/OpenSpartan.Workshop/Core/WorkshopLogger.cs @@ -0,0 +1,34 @@ +using NLog; +using OpenSpartan.Workshop.Models; +using OpenSpartan.Workshop.ViewModels; +using System.Runtime.CompilerServices; + +namespace OpenSpartan.Workshop.Core +{ + internal class LogEngine + { + private static readonly NLog.Logger Logger = LogManager.GetCurrentClassLogger(); + + internal static void Log(string message, LogSeverity severity = LogSeverity.Info, [CallerMemberName] string caller = null) + { + if (SettingsViewModel.Instance.EnableLogging) + { + switch (severity) + { + case LogSeverity.Warning: + Logger.Warn($"[{caller}] {message}"); + break; + case LogSeverity.Error: + Logger.Error($"[{caller}] {message}"); + break; + case LogSeverity.Info: + Logger.Info($"[{caller}] {message}"); + break; + default: + Logger.Info($"[{caller}] {message}"); + break; + } + } + } + } +} diff --git a/src/OpenSpartan.Workshop/CustomImages/cr.svg b/src/OpenSpartan.Workshop/CustomImages/cr.svg new file mode 100644 index 0000000..807470c --- /dev/null +++ b/src/OpenSpartan.Workshop/CustomImages/cr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/OpenSpartan.Workshop/CustomImages/sp.svg b/src/OpenSpartan.Workshop/CustomImages/sp.svg new file mode 100644 index 0000000..2d1c958 --- /dev/null +++ b/src/OpenSpartan.Workshop/CustomImages/sp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/OpenSpartan.Workshop/Data/DataHandler.cs b/src/OpenSpartan.Workshop/Data/DataHandler.cs index 91e516d..b571c63 100644 --- a/src/OpenSpartan.Workshop/Data/DataHandler.cs +++ b/src/OpenSpartan.Workshop/Data/DataHandler.cs @@ -1,7 +1,7 @@ using Den.Dev.Orion.Converters; using Den.Dev.Orion.Models.HaloInfinite; using Microsoft.Data.Sqlite; -using NLog; +using Microsoft.UI.Xaml.Controls; using OpenSpartan.Workshop.Core; using OpenSpartan.Workshop.Models; using OpenSpartan.Workshop.ViewModels; @@ -10,18 +10,17 @@ using System.Data; using System.Globalization; using System.IO; -using System.Reflection; +using System.Linq; using System.Text; using System.Text.Json; +using System.Text.RegularExpressions; using System.Threading.Tasks; using System.Xml; namespace OpenSpartan.Workshop.Data { - internal sealed class DataHandler + internal static class DataHandler { - private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); - internal static string DatabasePath => Path.Combine(Core.Configuration.AppDataDirectory, "data", $"{HomeViewModel.Instance.Xuid}.db"); private static readonly JsonSerializerOptions serializerOptions = new() @@ -54,12 +53,12 @@ internal static string SetWALJournalingMode() } else { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error($"WAL journaling mode not set."); + LogEngine.Log($"WAL journaling mode not set.", LogSeverity.Error); } } catch (Exception ex) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error($"Journaling mode modification exception: {ex.Message}"); + LogEngine.Log($"Journaling mode modification exception: {ex.Message}", LogSeverity.Error); } return null; @@ -85,6 +84,7 @@ internal static bool BootstrapDatabase() BootstrapTableIfNotExists(connection, "OperationRewardTracks"); BootstrapTableIfNotExists(connection, "InventoryItems"); BootstrapTableIfNotExists(connection, "OwnedInventoryItems"); + BootstrapTableIfNotExists(connection, "PlaylistCSRSnapshots"); SetupIndices(connection); @@ -92,7 +92,7 @@ internal static bool BootstrapDatabase() } catch (Exception ex) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error($"Database bootstrapping failure: {ex.Message}"); + LogEngine.Log($"Database bootstrapping failure: {ex.Message}", LogSeverity.Error); return false; } } @@ -120,11 +120,11 @@ private static void SetupIndices(SqliteConnection connection) if (outcome > 0) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Info("Indices provisioned."); + LogEngine.Log("Indices provisioned."); } else { - if (SettingsViewModel.Instance.EnableLogging) Logger.Warn("Indices could not be set up. If this is not the first run, then those are likely already configured."); + LogEngine.Log("Indices could not be set up. If this is not the first run, then those are likely already configured.", LogSeverity.Warning); } } @@ -152,7 +152,38 @@ internal static bool InsertServiceRecordEntry(string serviceRecordJson) } catch (Exception ex) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error($"Error inserting service record entry. {ex.Message}"); + LogEngine.Log($"Error inserting service record entry. {ex.Message}", LogSeverity.Error); + return false; + } + } + + internal static bool InsertPlaylistCSRSnapshot(string playlistId, string playlistVersion, string playlistCsrJson) + { + try + { + using var connection = new SqliteConnection($"Data Source={DatabasePath}"); + connection.Open(); + + var command = connection.CreateCommand(); + command.CommandText = GetQuery("Insert", "PlaylistCSR"); ; + command.Parameters.AddWithValue("$ResponseBody", playlistCsrJson); + command.Parameters.AddWithValue("$PlaylistId", playlistId); + command.Parameters.AddWithValue("$PlaylistVersion", playlistVersion); + command.Parameters.AddWithValue("$SnapshotTimestamp", DateTimeOffset.Now.ToString("yyyy-MM-dd'T'HH:mm:ss.fffK", CultureInfo.InvariantCulture)); + + using var reader = command.ExecuteReader(); + if (reader.RecordsAffected > 1) + { + return true; + } + else + { + return false; + } + } + catch (Exception ex) + { + LogEngine.Log($"Error inserting playlist CSR entry. {ex.Message}", LogSeverity.Error); return false; } } @@ -180,12 +211,12 @@ internal static List GetMatchIds() } else { - if (SettingsViewModel.Instance.EnableLogging) Logger.Warn($"No rows returned for distinct match IDs."); + LogEngine.Log($"No rows returned for distinct match IDs.", LogSeverity.Warning); } } catch (Exception ex) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error($"An error occurred obtaining unique match IDs. {ex.Message}"); + LogEngine.Log($"An error occurred obtaining unique match IDs. {ex.Message}", LogSeverity.Error); } return null; @@ -216,17 +247,50 @@ internal static RewardTrackMetadata GetOperationResponseBody(string operationPat } else { - if (SettingsViewModel.Instance.EnableLogging) Logger.Warn($"No rows returned for operations."); + LogEngine.Log($"No rows returned for operations.", LogSeverity.Warning); } } catch (Exception ex) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error($"An error occurred obtaining operations from database. {ex.Message}"); + LogEngine.Log($"An error occurred obtaining operations from database. {ex.Message}", LogSeverity.Error); } return null; } + internal static int GetExistingMatchCount(IEnumerable matchIds) + { + try + { + using SqliteConnection connection = new($"Data Source={DatabasePath}"); + connection.Open(); + + // In this context, we want the command to be literal rather than parameterized. + using var command = connection.CreateCommand(); + command.CommandText = GetQuery("Select", "ExistingMatchCount").Replace("$MatchGUIDList", string.Join(", ", matchIds.Select(g => $"'{g}'")), StringComparison.InvariantCultureIgnoreCase); + + using SqliteDataReader reader = command.ExecuteReader(); + if (reader.HasRows) + { + while (reader.Read()) + { + var resultOrdinal = reader.GetOrdinal("ExistingMatchCount"); + return reader.IsDBNull(resultOrdinal) ? -1 : reader.GetFieldValue(resultOrdinal); + } + } + else + { + LogEngine.Log($"No rows returned for existing match metadata.", LogSeverity.Warning); + } + } + catch (Exception ex) + { + LogEngine.Log($"An error occurred obtaining match records from database. {ex.Message}", LogSeverity.Error); + } + + return -1; + } + internal static List GetMatches(string playerXuid, string boundaryTime, int boundaryLimit) { return GetMatchesInternal(playerXuid, null, boundaryTime, boundaryLimit); @@ -279,12 +343,12 @@ private static List GetMatchesInternal(string playerXuid, long } else { - if (SettingsViewModel.Instance.EnableLogging) Logger.Warn($"No rows returned for player match IDs."); + LogEngine.Log($"No rows returned for player match IDs.", LogSeverity.Warning); } } catch (Exception ex) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error($"An error occurred obtaining matches. {ex.Message}"); + LogEngine.Log($"An error occurred obtaining matches. {ex.Message}", LogSeverity.Error); } return null; @@ -294,6 +358,7 @@ private static MatchTableEntity ReadMatchTableEntity(SqliteDataReader reader) { var matchOrdinal = reader.GetOrdinal("MatchId"); var startTimeOrdinal = reader.GetOrdinal("StartTime"); + var endTimeOrdinal = reader.GetOrdinal("EndTime"); var rankOrdinal = reader.GetOrdinal("Rank"); var outcomeOrdinal = reader.GetOrdinal("Outcome"); var gameVariantCategoryOrdinal = reader.GetOrdinal("GameVariantCategory"); @@ -308,6 +373,18 @@ private static MatchTableEntity ReadMatchTableEntity(SqliteDataReader reader) var teamMmrOrdinal = reader.GetOrdinal("TeamMmr"); var expectedDeathsOrdinal = reader.GetOrdinal("ExpectedDeaths"); var expectedKillsOrdinal = reader.GetOrdinal("ExpectedKills"); + var expectedBronzeDeathsOrdinal = reader.GetOrdinal("ExpectedBronzeDeaths"); + var expectedBronzeKillsOrdinal = reader.GetOrdinal("ExpectedBronzeKills"); + var expectedSilverDeathsOrdinal = reader.GetOrdinal("ExpectedSilverDeaths"); + var expectedSilverKillsOrdinal = reader.GetOrdinal("ExpectedSilverKills"); + var expectedGoldDeathsOrdinal = reader.GetOrdinal("ExpectedGoldDeaths"); + var expectedGoldKillsOrdinal = reader.GetOrdinal("ExpectedGoldKills"); + var expectedPlatinumDeathsOrdinal = reader.GetOrdinal("ExpectedPlatinumDeaths"); + var expectedPlatinumKillsOrdinal = reader.GetOrdinal("ExpectedPlatinumKills"); + var expectedDiamondDeathsOrdinal = reader.GetOrdinal("ExpectedDiamondDeaths"); + var expectedDiamondKillsOrdinal = reader.GetOrdinal("ExpectedDiamondKills"); + var expectedOnyxDeathsOrdinal = reader.GetOrdinal("ExpectedOnyxDeaths"); + var expectedOnyxKillsOrdinal = reader.GetOrdinal("ExpectedOnyxKills"); var postMatchOrdinal = reader.GetOrdinal("PostMatchCsr"); var preMatchCsrOrdinal = reader.GetOrdinal("PreMatchCsr"); var tierOrdinal = reader.GetOrdinal("Tier"); @@ -323,6 +400,7 @@ private static MatchTableEntity ReadMatchTableEntity(SqliteDataReader reader) { MatchId = reader.IsDBNull(matchOrdinal) ? string.Empty : reader.GetFieldValue(matchOrdinal), StartTime = reader.IsDBNull(startTimeOrdinal) ? DateTimeOffset.UnixEpoch : reader.GetFieldValue(startTimeOrdinal).ToLocalTime(), + EndTime = reader.IsDBNull(startTimeOrdinal) ? DateTimeOffset.UnixEpoch : reader.GetFieldValue(endTimeOrdinal).ToLocalTime(), Rank = reader.IsDBNull(rankOrdinal) ? 0 : reader.GetFieldValue(rankOrdinal), Outcome = reader.IsDBNull(outcomeOrdinal) ? Outcome.DidNotFinish : reader.GetFieldValue(outcomeOrdinal), Category = reader.IsDBNull(gameVariantCategoryOrdinal) ? GameVariantCategory.None : reader.GetFieldValue(gameVariantCategoryOrdinal), @@ -337,6 +415,18 @@ private static MatchTableEntity ReadMatchTableEntity(SqliteDataReader reader) TeamMmr = reader.IsDBNull(teamMmrOrdinal) ? null : reader.GetFieldValue(teamMmrOrdinal), ExpectedDeaths = reader.IsDBNull(expectedDeathsOrdinal) ? null : reader.GetFieldValue(expectedDeathsOrdinal), ExpectedKills = reader.IsDBNull(expectedKillsOrdinal) ? null : reader.GetFieldValue(expectedKillsOrdinal), + ExpectedBronzeDeaths = reader.IsDBNull(expectedBronzeDeathsOrdinal) ? null : reader.GetFieldValue(expectedBronzeDeathsOrdinal), + ExpectedBronzeKills = reader.IsDBNull(expectedBronzeKillsOrdinal) ? null : reader.GetFieldValue(expectedBronzeKillsOrdinal), + ExpectedSilverDeaths = reader.IsDBNull(expectedSilverDeathsOrdinal) ? null : reader.GetFieldValue(expectedSilverDeathsOrdinal), + ExpectedSilverKills = reader.IsDBNull(expectedSilverKillsOrdinal) ? null : reader.GetFieldValue(expectedSilverKillsOrdinal), + ExpectedGoldDeaths = reader.IsDBNull(expectedGoldDeathsOrdinal) ? null : reader.GetFieldValue(expectedGoldDeathsOrdinal), + ExpectedGoldKills = reader.IsDBNull(expectedGoldKillsOrdinal) ? null : reader.GetFieldValue(expectedGoldKillsOrdinal), + ExpectedPlatinumDeaths = reader.IsDBNull(expectedPlatinumDeathsOrdinal) ? null : reader.GetFieldValue(expectedPlatinumDeathsOrdinal), + ExpectedPlatinumKills = reader.IsDBNull(expectedPlatinumKillsOrdinal) ? null : reader.GetFieldValue(expectedPlatinumKillsOrdinal), + ExpectedDiamondDeaths = reader.IsDBNull(expectedDiamondDeathsOrdinal) ? null : reader.GetFieldValue(expectedDiamondDeathsOrdinal), + ExpectedDiamondKills = reader.IsDBNull(expectedDiamondKillsOrdinal) ? null : reader.GetFieldValue(expectedDiamondKillsOrdinal), + ExpectedOnyxDeaths = reader.IsDBNull(expectedOnyxDeathsOrdinal) ? null : reader.GetFieldValue(expectedOnyxDeathsOrdinal), + ExpectedOnyxKills = reader.IsDBNull(expectedOnyxKillsOrdinal) ? null : reader.GetFieldValue(expectedOnyxKillsOrdinal), PostMatchCsr = reader.IsDBNull(postMatchOrdinal) ? null : reader.GetFieldValue(postMatchOrdinal), PreMatchCsr = reader.IsDBNull(preMatchCsrOrdinal) ? null : reader.GetFieldValue(preMatchCsrOrdinal), Tier = reader.IsDBNull(tierOrdinal) ? null : reader.GetFieldValue(tierOrdinal), @@ -372,7 +462,7 @@ internal static (bool MatchAvailable, bool StatsAvailable) GetMatchStatsAvailabi } catch (Exception ex) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error($"An error occurred obtaining match and stats availability. {ex.Message}"); + LogEngine.Log($"An error occurred obtaining match and stats availability. {ex.Message}", LogSeverity.Error); } return (false, false); // Default values if the data retrieval fails @@ -399,7 +489,7 @@ internal static bool InsertPlayerMatchStats(string matchId, string statsBody) } catch (Exception ex) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error($"An error occurred inserting player match and stats. {ex.Message}"); + LogEngine.Log($"An error occurred inserting player match and stats. {ex.Message}", LogSeverity.Error); } return false; @@ -425,7 +515,7 @@ internal static bool InsertMatchStats (string matchBody) } catch (Exception ex) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error($"An error occurred inserting match and stats. {ex.Message}"); + LogEngine.Log($"An error occurred inserting match and stats. {ex.Message}", LogSeverity.Error); } return false; @@ -502,7 +592,7 @@ internal static async Task UpdateMatchAssetRecords(MatchStats result) if (insertionResult > 0) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Info($"Stored map: {result.MatchInfo.MapVariant.AssetId}/{result.MatchInfo.MapVariant.VersionId}"); + LogEngine.Log($"Stored map: {result.MatchInfo.MapVariant.AssetId}/{result.MatchInfo.MapVariant.VersionId}"); } } } @@ -522,7 +612,7 @@ internal static async Task UpdateMatchAssetRecords(MatchStats result) if (insertionResult > 0) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Info($"Stored playlist: {result.MatchInfo.Playlist.AssetId}/{result.MatchInfo.Playlist.VersionId}"); + LogEngine.Log($"Stored playlist: {result.MatchInfo.Playlist.AssetId}/{result.MatchInfo.Playlist.VersionId}"); } } } @@ -543,7 +633,7 @@ internal static async Task UpdateMatchAssetRecords(MatchStats result) if (insertionResult > 0) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Info($"Stored playlist + map mode pair: {result.MatchInfo.PlaylistMapModePair.AssetId}/{result.MatchInfo.PlaylistMapModePair.VersionId}"); + LogEngine.Log($"Stored playlist + map mode pair: {result.MatchInfo.PlaylistMapModePair.AssetId}/{result.MatchInfo.PlaylistMapModePair.VersionId}"); } } } @@ -565,7 +655,7 @@ internal static async Task UpdateMatchAssetRecords(MatchStats result) if (insertionResult > 0) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Info($"Stored game variant: {result.MatchInfo.UgcGameVariant.AssetId}/{result.MatchInfo.UgcGameVariant.VersionId}"); + LogEngine.Log($"Stored game variant: {result.MatchInfo.UgcGameVariant.AssetId}/{result.MatchInfo.UgcGameVariant.VersionId}"); } } @@ -594,7 +684,7 @@ internal static async Task UpdateMatchAssetRecords(MatchStats result) if (insertionResult > 0) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Info($"Stored engine game variant: {engineGameVariant.Result.AssetId}/{engineGameVariant.Result.VersionId}"); + LogEngine.Log($"Stored engine game variant: {engineGameVariant.Result.AssetId}/{engineGameVariant.Result.VersionId}"); } } } @@ -603,7 +693,7 @@ internal static async Task UpdateMatchAssetRecords(MatchStats result) } catch (Exception ex) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error($"Error updating match stats. {ex.Message}"); + LogEngine.Log($"Error updating match stats. {ex.Message}", LogSeverity.Error); return false; } } @@ -611,7 +701,7 @@ internal static async Task UpdateMatchAssetRecords(MatchStats result) private static string GetQuery(string category, string target) { - return System.IO.File.ReadAllText(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "Queries", category, $"{target}.sql"), Encoding.UTF8); + return System.IO.File.ReadAllText(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Queries", category, $"{target}.sql"), Encoding.UTF8); } internal static List GetMedals() @@ -637,12 +727,12 @@ internal static List GetMedals() } else { - if (SettingsViewModel.Instance.EnableLogging) Logger.Warn($"No rows returned for medals."); + LogEngine.Log($"No rows returned for medals.", LogSeverity.Warning); } } catch (Exception ex) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error($"An error occurred obtaining medals from the database. {ex.Message}"); + LogEngine.Log($"An error occurred obtaining medals from the database. {ex.Message}", LogSeverity.Error); } return null; @@ -664,7 +754,7 @@ internal static bool UpdateOperationRewardTracks(string response, string path) if (insertionResult > 0) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Info($"Stored reward track {path}."); + LogEngine.Log($"Stored reward track {path}."); return true; } else @@ -689,7 +779,7 @@ internal static bool UpdateInventoryItems(string response, string path) if (insertionResult > 0) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Info($"Stored inventory item {path}."); + LogEngine.Log($"Stored inventory item {path}."); return true; } else @@ -777,12 +867,12 @@ internal static InGameItem GetInventoryItem(string path) } else { - if (SettingsViewModel.Instance.EnableLogging) Logger.Info($"No rows returned for inventory items query."); + LogEngine.Log($"No rows returned for inventory items query."); } } catch (Exception ex) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error($"An error occurred obtaining inventory items. {ex.Message}"); + LogEngine.Log($"An error occurred obtaining inventory items. {ex.Message}", LogSeverity.Error); } return null; @@ -811,11 +901,11 @@ internal static async Task InsertOwnedInventoryItems(PlayerInventory resul if (insertionResult > 0) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Info($"Stored owned inventory item {item.ItemId}."); + LogEngine.Log($"Stored owned inventory item {item.ItemId}."); } else { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error($"Could not store owned inventory item {item.ItemId}."); + LogEngine.Log($"Could not store owned inventory item {item.ItemId}.", LogSeverity.Error); } } @@ -823,7 +913,7 @@ internal static async Task InsertOwnedInventoryItems(PlayerInventory resul } catch (Exception ex) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error($"Error inserting owned inventory items. {ex.Message}"); + LogEngine.Log($"Error inserting owned inventory items. {ex.Message}", LogSeverity.Error); return false; } } diff --git a/src/OpenSpartan.Workshop/Data/Extensions.cs b/src/OpenSpartan.Workshop/Data/Extensions.cs index 79ed474..29101d1 100644 --- a/src/OpenSpartan.Workshop/Data/Extensions.cs +++ b/src/OpenSpartan.Workshop/Data/Extensions.cs @@ -1,4 +1,6 @@ using Microsoft.Data.Sqlite; +using OpenSpartan.Workshop.Core; +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; @@ -42,12 +44,22 @@ public static bool BootstrapTable(this SqliteConnection connection, string table return true; } - catch + catch (Exception ex) { + LogEngine.Log($"Could not bootstrap table {tableName}. {ex.Message}", Models.LogSeverity.Error); return false; } } - public static void AddRange(this ObservableCollection collection, IEnumerable items) => items.ToList().ForEach(collection.Add); + public static void AddRange(this ObservableCollection collection, IEnumerable items) + { + ArgumentNullException.ThrowIfNull(collection); + ArgumentNullException.ThrowIfNull(items); + + foreach (var item in items) + { + collection.Add(item); + } + } } } diff --git a/src/OpenSpartan.Workshop/Data/MatchesSource.cs b/src/OpenSpartan.Workshop/Data/MatchesSource.cs index 666fa8a..0f4b7da 100644 --- a/src/OpenSpartan.Workshop/Data/MatchesSource.cs +++ b/src/OpenSpartan.Workshop/Data/MatchesSource.cs @@ -1,4 +1,4 @@ -using CommunityToolkit.Common.Collections; +using CommunityToolkit.WinUI.Collections; using OpenSpartan.Workshop.Core; using OpenSpartan.Workshop.Models; using OpenSpartan.Workshop.ViewModels; diff --git a/src/OpenSpartan.Workshop/Data/MedalMatchesSource.cs b/src/OpenSpartan.Workshop/Data/MedalMatchesSource.cs index 663f402..945c430 100644 --- a/src/OpenSpartan.Workshop/Data/MedalMatchesSource.cs +++ b/src/OpenSpartan.Workshop/Data/MedalMatchesSource.cs @@ -1,5 +1,5 @@ -using CommunityToolkit.Common.Collections; -using CommunityToolkit.WinUI; +using CommunityToolkit.WinUI; +using CommunityToolkit.WinUI.Collections; using OpenSpartan.Workshop.Core; using OpenSpartan.Workshop.Models; using OpenSpartan.Workshop.ViewModels; diff --git a/src/OpenSpartan.Workshop/MainWindow.xaml b/src/OpenSpartan.Workshop/MainWindow.xaml index f365d4e..c48a15a 100644 --- a/src/OpenSpartan.Workshop/MainWindow.xaml +++ b/src/OpenSpartan.Workshop/MainWindow.xaml @@ -63,6 +63,21 @@ + + + + + + + + + + + + + + + diff --git a/src/OpenSpartan.Workshop/Models/ItemClass.cs b/src/OpenSpartan.Workshop/Models/ItemClass.cs new file mode 100644 index 0000000..bb3089b --- /dev/null +++ b/src/OpenSpartan.Workshop/Models/ItemClass.cs @@ -0,0 +1,12 @@ +namespace OpenSpartan.Workshop.Models +{ + public enum ItemClass + { + StandardReward = 0, + SpartanPoints = 1, + Credits = 2, + XPBoost = 3, + XPGrant = 4, + ChallengeReroll = 5, + } +} \ No newline at end of file diff --git a/src/OpenSpartan.Workshop/Models/RewardMetaContainer.cs b/src/OpenSpartan.Workshop/Models/ItemMetadataContainer.cs similarity index 50% rename from src/OpenSpartan.Workshop/Models/RewardMetaContainer.cs rename to src/OpenSpartan.Workshop/Models/ItemMetadataContainer.cs index 2271514..8fc83e8 100644 --- a/src/OpenSpartan.Workshop/Models/RewardMetaContainer.cs +++ b/src/OpenSpartan.Workshop/Models/ItemMetadataContainer.cs @@ -3,7 +3,7 @@ namespace OpenSpartan.Workshop.Models { - internal sealed class RewardMetaContainer + internal sealed class ItemMetadataContainer { public bool IsFree { get; set; } @@ -15,6 +15,16 @@ internal sealed class RewardMetaContainer public string ImagePath { get; set; } - public int Amount { get; set; } + /// + /// Gets or sets the numeric value associated with the currency + /// or item cost. + /// + public int ItemValue { get; set; } + + public ItemClass Type { get; set; } + + public string ItemType { get; set; } + + public string ItemPath { get; set; } } } diff --git a/src/OpenSpartan.Workshop/Models/LogSeverity.cs b/src/OpenSpartan.Workshop/Models/LogSeverity.cs new file mode 100644 index 0000000..b3a32e6 --- /dev/null +++ b/src/OpenSpartan.Workshop/Models/LogSeverity.cs @@ -0,0 +1,9 @@ +namespace OpenSpartan.Workshop.Models +{ + internal enum LogSeverity + { + Info, + Warning, + Error, + } +} diff --git a/src/OpenSpartan.Workshop/Models/MatchTableEntity.cs b/src/OpenSpartan.Workshop/Models/MatchTableEntity.cs index 6e2298f..6ebc4dd 100644 --- a/src/OpenSpartan.Workshop/Models/MatchTableEntity.cs +++ b/src/OpenSpartan.Workshop/Models/MatchTableEntity.cs @@ -10,6 +10,8 @@ internal sealed class MatchTableEntity public DateTimeOffset StartTime { get; set; } + public DateTimeOffset EndTime { get; set; } + public List Teams { get; set; } public TimeSpan Duration { get; set; } @@ -38,6 +40,30 @@ internal sealed class MatchTableEntity public float? ExpectedKills { get; set; } + public float? ExpectedBronzeDeaths { get; set; } + + public float? ExpectedBronzeKills { get; set; } + + public float? ExpectedSilverDeaths { get; set; } + + public float? ExpectedSilverKills { get; set; } + + public float? ExpectedGoldDeaths { get; set; } + + public float? ExpectedGoldKills { get; set; } + + public float? ExpectedPlatinumDeaths { get; set; } + + public float? ExpectedPlatinumKills { get; set; } + + public float? ExpectedDiamondDeaths { get; set; } + + public float? ExpectedDiamondKills { get; set; } + + public float? ExpectedOnyxDeaths { get; set; } + + public float? ExpectedOnyxKills { get; set; } + public int? PostMatchCsr { get; set; } public int? PreMatchCsr { get; set; } diff --git a/src/OpenSpartan.Workshop/Models/OperationCompoundModel.cs b/src/OpenSpartan.Workshop/Models/OperationCompoundModel.cs index 125db6f..3f5609a 100644 --- a/src/OpenSpartan.Workshop/Models/OperationCompoundModel.cs +++ b/src/OpenSpartan.Workshop/Models/OperationCompoundModel.cs @@ -8,13 +8,15 @@ internal sealed class OperationCompoundModel { public OperationCompoundModel() { - Rewards = new(); + Rewards = []; } - public RewardTrack RewardTrack { get; set; } + public RewardTrack? RewardTrack { get; set; } - public RewardTrackMetadata RewardTrackMetadata { get; set; } + public RewardTrackMetadata? RewardTrackMetadata { get; set; } - public ObservableCollection> Rewards { get; set; } + public ObservableCollection>? Rewards { get; set; } + + public SeasonRewardTrack? SeasonRewardTrack { get; set; } } } diff --git a/src/OpenSpartan.Workshop/Models/PlaylistCSRSnapshot.cs b/src/OpenSpartan.Workshop/Models/PlaylistCSRSnapshot.cs new file mode 100644 index 0000000..cff1535 --- /dev/null +++ b/src/OpenSpartan.Workshop/Models/PlaylistCSRSnapshot.cs @@ -0,0 +1,16 @@ +using Den.Dev.Orion.Models.HaloInfinite; +using System; + +namespace OpenSpartan.Workshop.Models +{ + internal class PlaylistCSRSnapshot + { + public string Name { get; set; } + + public Guid Id { get; set; } + + public Guid Version { get; set; } + + public PlaylistCsrResults Snapshot { get; set; } + } +} diff --git a/src/OpenSpartan.Workshop/Models/SeasonCalendarViewDayItem.cs b/src/OpenSpartan.Workshop/Models/SeasonCalendarViewDayItem.cs new file mode 100644 index 0000000..3cffa58 --- /dev/null +++ b/src/OpenSpartan.Workshop/Models/SeasonCalendarViewDayItem.cs @@ -0,0 +1,25 @@ +using Microsoft.UI.Xaml.Media; +using System; + +namespace OpenSpartan.Workshop.Models +{ + public class SeasonCalendarViewDayItem + { + public SeasonCalendarViewDayItem(DateTime dateTime, string text, SolidColorBrush markerColor) + { + DateTime = dateTime; + CSRSeasonText = text; + CSRSeasonMarkerColor = markerColor; + } + + public DateTime DateTime { get; } + + public string CSRSeasonText { get; } + + public SolidColorBrush CSRSeasonMarkerColor { get; set; } + + public string RegularSeasonText { get; set; } + + public SolidColorBrush RegularSeasonMarkerColor { get; set; } + } +} diff --git a/src/OpenSpartan.Workshop/Models/WorkshopSettings.cs b/src/OpenSpartan.Workshop/Models/WorkshopSettings.cs index 6ddc0a3..6bcec42 100644 --- a/src/OpenSpartan.Workshop/Models/WorkshopSettings.cs +++ b/src/OpenSpartan.Workshop/Models/WorkshopSettings.cs @@ -1,4 +1,5 @@ using OpenSpartan.Workshop.Core; +using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Text.Json.Serialization; @@ -11,6 +12,19 @@ internal sealed class WorkshopSettings : Observable private bool _enableLogging; private string _apiVersion; private string _headerImagePath; + private bool _useBroker; + private string _sandbox; + private string _build; + private bool _useObanClearance; + private bool _enableLooseMatchSearch; + private List _extraRitualEvents; + private List _excludedOperations; + + public WorkshopSettings() + { + ExcludedOperations = []; + ExtraRitualEvents = []; + } [JsonPropertyName("release")] public string Release @@ -82,6 +96,104 @@ public string HeaderImagePath } } + [JsonPropertyName("usebroker")] + public bool UseBroker + { + get => _useBroker; + set + { + if (_useBroker != value) + { + _useBroker = value; + NotifyPropertyChanged(); + } + } + } + + [JsonPropertyName("loosematchsearch")] + public bool EnableLooseMatchSearch + { + get => _enableLooseMatchSearch; + set + { + if (_enableLooseMatchSearch != value) + { + _enableLooseMatchSearch = value; + NotifyPropertyChanged(); + } + } + } + + [JsonPropertyName("sandbox")] + public string Sandbox + { + get => _sandbox; + set + { + if (_sandbox != value) + { + _sandbox = value; + NotifyPropertyChanged(); + } + } + } + + [JsonPropertyName("build")] + public string Build + { + get => _build; + set + { + if (_build != value) + { + _build = value; + NotifyPropertyChanged(); + } + } + } + + [JsonPropertyName("useObanClearance")] + public bool UseObanClearance + { + get => _useObanClearance; + set + { + if (_useObanClearance != value) + { + _useObanClearance = value; + NotifyPropertyChanged(); + } + } + } + + [JsonPropertyName("extraRitualEvents")] + public List ExtraRitualEvents + { + get => _extraRitualEvents; + set + { + if (_extraRitualEvents != value) + { + _extraRitualEvents = value; + NotifyPropertyChanged(); + } + } + } + + [JsonPropertyName("excludedOperations")] + public List ExcludedOperations + { + get => _excludedOperations; + set + { + if (_excludedOperations != value) + { + _excludedOperations = value; + NotifyPropertyChanged(); + } + } + } + public void NotifyPropertyChanged([CallerMemberName] string propertyName = null) { OnPropertyChanged(propertyName); diff --git a/src/OpenSpartan.Workshop/NLog.config b/src/OpenSpartan.Workshop/NLog.config index b1b1d5a..d220b7d 100644 --- a/src/OpenSpartan.Workshop/NLog.config +++ b/src/OpenSpartan.Workshop/NLog.config @@ -2,8 +2,8 @@ - - + + diff --git a/src/OpenSpartan.Workshop/OpenSpartan.Workshop.csproj b/src/OpenSpartan.Workshop/OpenSpartan.Workshop.csproj index c7221bf..ea65292 100644 --- a/src/OpenSpartan.Workshop/OpenSpartan.Workshop.csproj +++ b/src/OpenSpartan.Workshop/OpenSpartan.Workshop.csproj @@ -1,201 +1,240 @@  - - WinExe - net8.0-windows10.0.19041.0 - 10.0.19041.0 - OpenSpartan.Workshop - app.manifest - x86;x64;ARM64 - win-x86;win-x64;win-arm64 - win-$(Platform).pubxml - true - false - None - true - All - 1.0.3.0 - 1.0.3.0 - - - - - - - - - - - - - - - - Always - - - Always - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - MSBuild:Compile - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - MSBuild:Compile - - - MSBuild:Compile - - - MSBuild:Compile - - - MSBuild:Compile - - - MSBuild:Compile - - - - - MSBuild:Compile - - - - - MSBuild:Compile - - + + + + + + MSBuild:Compile + + + MSBuild:Compile + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + MSBuild:Compile + + + MSBuild:Compile + + + MSBuild:Compile + + + MSBuild:Compile + + + MSBuild:Compile + + + MSBuild:Compile + + + MSBuild:Compile + + + MSBuild:Compile + + + MSBuild:Compile + + + + + MSBuild:Compile + + + + + MSBuild:Compile + + diff --git a/src/OpenSpartan.Workshop/OpenSpartan.Workshop.csproj.user b/src/OpenSpartan.Workshop/OpenSpartan.Workshop.csproj.user index 42188ad..dc5f6a9 100644 --- a/src/OpenSpartan.Workshop/OpenSpartan.Workshop.csproj.user +++ b/src/OpenSpartan.Workshop/OpenSpartan.Workshop.csproj.user @@ -23,6 +23,24 @@ Designer + + Designer + + + Designer + + + Designer + + + Designer + + + Designer + + + Designer + Designer diff --git a/src/OpenSpartan.Workshop/Queries/Bootstrap/PlaylistCSRSnapshots.sql b/src/OpenSpartan.Workshop/Queries/Bootstrap/PlaylistCSRSnapshots.sql new file mode 100644 index 0000000..8a82488 --- /dev/null +++ b/src/OpenSpartan.Workshop/Queries/Bootstrap/PlaylistCSRSnapshots.sql @@ -0,0 +1,7 @@ +CREATE TABLE PlaylistCSRSnapshots ( + ResponseBody TEXT, + PlaylistId TEXT, + PlaylistVersion TEXT, + SnapshotTimestamp DATETIME, + Value Text GENERATED ALWAYS AS (json_extract(ResponseBody, '$.Value')) VIRTUAL +); \ No newline at end of file diff --git a/src/OpenSpartan.Workshop/Queries/Insert/PlaylistCSR.sql b/src/OpenSpartan.Workshop/Queries/Insert/PlaylistCSR.sql new file mode 100644 index 0000000..c9c6f0a --- /dev/null +++ b/src/OpenSpartan.Workshop/Queries/Insert/PlaylistCSR.sql @@ -0,0 +1,2 @@ +INSERT INTO PlaylistCSRSnapshots (ResponseBody, PlaylistId, PlaylistVersion, SnapshotTimestamp) +VALUES ($ResponseBody, $PlaylistId, $PlaylistVersion, $SnapshotTimestamp) \ No newline at end of file diff --git a/src/OpenSpartan.Workshop/Queries/Select/ExistingMatchCount.sql b/src/OpenSpartan.Workshop/Queries/Select/ExistingMatchCount.sql new file mode 100644 index 0000000..6b22465 --- /dev/null +++ b/src/OpenSpartan.Workshop/Queries/Select/ExistingMatchCount.sql @@ -0,0 +1,3 @@ +SELECT COUNT(*) AS ExistingMatchCount +FROM MatchStats +WHERE MatchId IN ($MatchGUIDList); \ No newline at end of file diff --git a/src/OpenSpartan.Workshop/Queries/Select/PlayerMatches.sql b/src/OpenSpartan.Workshop/Queries/Select/PlayerMatches.sql index fb367fe..a9af798 100644 --- a/src/OpenSpartan.Workshop/Queries/Select/PlayerMatches.sql +++ b/src/OpenSpartan.Workshop/Queries/Select/PlayerMatches.sql @@ -3,6 +3,7 @@ MS.MatchId, MS.Teams, json_extract(MS.MatchInfo, '$.StartTime') AS StartTime, + json_extract(MS.MatchInfo, '$.EndTime') AS EndTime, json_extract(MS.MatchInfo, '$.Duration') AS Duration, json_extract(MS.MatchInfo, '$.GameVariantCategory') AS GameVariantCategory, json_extract(MS.MatchInfo, '$.MapVariant.AssetId') AS Map, @@ -28,6 +29,18 @@ MATCH_DETAILS AS ( json_extract(PE.value, '$.Result.TeamMmr') AS TeamMmr, json_extract(PE.value, '$.Result.Counterfactuals.SelfCounterfactuals.Deaths') AS ExpectedDeaths, json_extract(PE.value, '$.Result.Counterfactuals.SelfCounterfactuals.Kills') AS ExpectedKills, + json_extract(PE.value, '$.Result.Counterfactuals.TierCounterfactuals.Bronze.Deaths') AS ExpectedBronzeDeaths, + json_extract(PE.value, '$.Result.Counterfactuals.TierCounterfactuals.Bronze.Kills') AS ExpectedBronzeKills, + json_extract(PE.value, '$.Result.Counterfactuals.TierCounterfactuals.Silver.Deaths') AS ExpectedSilverDeaths, + json_extract(PE.value, '$.Result.Counterfactuals.TierCounterfactuals.Silver.Kills') AS ExpectedSilverKills, + json_extract(PE.value, '$.Result.Counterfactuals.TierCounterfactuals.Gold.Deaths') AS ExpectedGoldDeaths, + json_extract(PE.value, '$.Result.Counterfactuals.TierCounterfactuals.Gold.Kills') AS ExpectedGoldKills, + json_extract(PE.value, '$.Result.Counterfactuals.TierCounterfactuals.Platinum.Deaths') AS ExpectedPlatinumDeaths, + json_extract(PE.value, '$.Result.Counterfactuals.TierCounterfactuals.Platinum.Kills') AS ExpectedPlatinumKills, + json_extract(PE.value, '$.Result.Counterfactuals.TierCounterfactuals.Diamond.Deaths') AS ExpectedDiamondDeaths, + json_extract(PE.value, '$.Result.Counterfactuals.TierCounterfactuals.Diamond.Kills') AS ExpectedDiamondKills, + json_extract(PE.value, '$.Result.Counterfactuals.TierCounterfactuals.Onyx.Deaths') AS ExpectedOnyxDeaths, + json_extract(PE.value, '$.Result.Counterfactuals.TierCounterfactuals.Onyx.Kills') AS ExpectedOnyxKills, json_extract(PE.value, '$.Result.RankRecap.PostMatchCsr.Value') AS PostMatchCsr, json_extract(PE.value, '$.Result.RankRecap.PreMatchCsr.Value') AS PreMatchCsr, json_extract(PE.value, '$.Result.RankRecap.PostMatchCsr.Tier') AS Tier, @@ -50,6 +63,7 @@ SELECTIVE_MATCHES AS ( MatchId, Teams, StartTime, + EndTime, Duration, "Rank", Outcome, @@ -71,6 +85,7 @@ SELECT SM.MatchId, SM.Teams, SM.StartTime, + SM.EndTime, SM.Duration, SM."Rank", SM.Outcome, @@ -84,6 +99,18 @@ SELECT MD.TeamMmr AS TeamMmr, MD.ExpectedDeaths AS ExpectedDeaths, MD.ExpectedKills AS ExpectedKills, + MD.ExpectedBronzeDeaths AS ExpectedBronzeDeaths, + MD.ExpectedBronzeKills AS ExpectedBronzeKills, + MD.ExpectedSilverDeaths AS ExpectedSilverDeaths, + MD.ExpectedSilverKills AS ExpectedSilverKills, + MD.ExpectedGoldDeaths AS ExpectedGoldDeaths, + MD.ExpectedGoldKills AS ExpectedGoldKills, + MD.ExpectedPlatinumDeaths AS ExpectedPlatinumDeaths, + MD.ExpectedPlatinumKills AS ExpectedPlatinumKills, + MD.ExpectedDiamondDeaths AS ExpectedDiamondDeaths, + MD.ExpectedDiamondKills AS ExpectedDiamondKills, + MD.ExpectedOnyxDeaths AS ExpectedOnyxDeaths, + MD.ExpectedOnyxKills AS ExpectedOnyxKills, MD.PostMatchCsr AS PostMatchCsr, MD.PreMatchCsr AS PreMatchCsr, MD.Tier AS Tier, @@ -107,4 +134,4 @@ LEFT JOIN GROUP BY SM.MatchId ORDER BY - StartTime DESC; + EndTime DESC; diff --git a/src/OpenSpartan.Workshop/Themes/Generic.xaml b/src/OpenSpartan.Workshop/Themes/Generic.xaml new file mode 100644 index 0000000..2a00ae3 --- /dev/null +++ b/src/OpenSpartan.Workshop/Themes/Generic.xaml @@ -0,0 +1,19 @@ + + + + diff --git a/src/OpenSpartan.Workshop/ViewModels/BattlePassViewModel.cs b/src/OpenSpartan.Workshop/ViewModels/BattlePassViewModel.cs index cc304cd..9c4522c 100644 --- a/src/OpenSpartan.Workshop/ViewModels/BattlePassViewModel.cs +++ b/src/OpenSpartan.Workshop/ViewModels/BattlePassViewModel.cs @@ -10,16 +10,18 @@ internal sealed class BattlePassViewModel : Observable private MetadataLoadingState _battlePassLoadingState; private string _battlePassLoadingParameter; private string _currentlySelectedBattlePass; + private string _currentlySelectedEvent; + private ObservableCollection _battlePasses; + private ObservableCollection _events; public static BattlePassViewModel Instance { get; } = new BattlePassViewModel(); private BattlePassViewModel() { BattlePasses = []; + Events = []; } - private ObservableCollection _battlePasses; - public ObservableCollection BattlePasses { get => _battlePasses; @@ -33,6 +35,19 @@ public ObservableCollection BattlePasses } } + public ObservableCollection Events + { + get => _events; + set + { + if (_events != value) + { + _events = value; + NotifyPropertyChanged(); + } + } + } + public string CurrentlySelectedBattlepass { get => _currentlySelectedBattlePass; @@ -46,6 +61,19 @@ public string CurrentlySelectedBattlepass } } + public string CurrentlySelectedEvent + { + get => _currentlySelectedEvent; + set + { + if (_currentlySelectedEvent != value) + { + _currentlySelectedEvent = value; + NotifyPropertyChanged(); + } + } + } + public string BattlePassLoadingString { get diff --git a/src/OpenSpartan.Workshop/ViewModels/ExchangeViewModel.cs b/src/OpenSpartan.Workshop/ViewModels/ExchangeViewModel.cs new file mode 100644 index 0000000..5e240f2 --- /dev/null +++ b/src/OpenSpartan.Workshop/ViewModels/ExchangeViewModel.cs @@ -0,0 +1,66 @@ +using Den.Dev.Orion.Models; +using OpenSpartan.Workshop.Core; +using OpenSpartan.Workshop.Models; +using System.Collections.ObjectModel; +using System.Runtime.CompilerServices; + +namespace OpenSpartan.Workshop.ViewModels +{ + internal class ExchangeViewModel : Observable + { + private APIFormattedDate _expirationDate; + private MetadataLoadingState _exchangeLoadingState; + private ObservableCollection _exchangeItems; + + public static ExchangeViewModel Instance { get; } = new ExchangeViewModel(); + + public ExchangeViewModel() + { + ExchangeItems = []; + } + + public ObservableCollection ExchangeItems + { + get => _exchangeItems; + set + { + if (_exchangeItems != value) + { + _exchangeItems = value; + NotifyPropertyChanged(); + } + } + } + + public APIFormattedDate ExpirationDate + { + get => _expirationDate; + set + { + if (_expirationDate != value) + { + _expirationDate = value; + NotifyPropertyChanged(); + } + } + } + + public MetadataLoadingState ExchangeLoadingState + { + get => _exchangeLoadingState; + set + { + if (_exchangeLoadingState != value) + { + _exchangeLoadingState = value; + NotifyPropertyChanged(); + } + } + } + + public void NotifyPropertyChanged([CallerMemberName] string propertyName = null) + { + OnPropertyChanged(propertyName); + } + } +} diff --git a/src/OpenSpartan.Workshop/ViewModels/HomeViewModel.cs b/src/OpenSpartan.Workshop/ViewModels/HomeViewModel.cs index e8826d7..dfbb658 100644 --- a/src/OpenSpartan.Workshop/ViewModels/HomeViewModel.cs +++ b/src/OpenSpartan.Workshop/ViewModels/HomeViewModel.cs @@ -1,6 +1,7 @@ using Den.Dev.Orion.Models.HaloInfinite; using OpenSpartan.Workshop.Core; using System; +using System.Globalization; using System.Runtime.CompilerServices; namespace OpenSpartan.Workshop.ViewModels @@ -292,10 +293,10 @@ public int? ExperienceRemaining public double? ExperienceProgress { - get => Convert.ToDouble(ExperienceEarnedToDate) / Convert.ToDouble(ExperienceTotalRequired); + get => Convert.ToDouble(ExperienceEarnedToDate, CultureInfo.InvariantCulture) / Convert.ToDouble(ExperienceTotalRequired, CultureInfo.InvariantCulture); } - public void NotifyPropertyChanged([CallerMemberName] string propertyName = null) + public void NotifyPropertyChanged([CallerMemberName] string? propertyName = null) { OnPropertyChanged(propertyName); } diff --git a/src/OpenSpartan.Workshop/ViewModels/MatchesViewModel.cs b/src/OpenSpartan.Workshop/ViewModels/MatchesViewModel.cs index e1d0014..357a0e7 100644 --- a/src/OpenSpartan.Workshop/ViewModels/MatchesViewModel.cs +++ b/src/OpenSpartan.Workshop/ViewModels/MatchesViewModel.cs @@ -1,4 +1,5 @@ using CommunityToolkit.WinUI; +using CommunityToolkit.WinUI.Collections; using OpenSpartan.Workshop.Core; using OpenSpartan.Workshop.Data; using OpenSpartan.Workshop.Models; @@ -86,7 +87,7 @@ private void NavigateToAnotherView(long parameter) NavigationRequested?.Invoke(this, parameter); } - public void NotifyPropertyChanged([CallerMemberName] string propertyName = null) + public void NotifyPropertyChanged([CallerMemberName] string? propertyName = null) { OnPropertyChanged(propertyName); } diff --git a/src/OpenSpartan.Workshop/ViewModels/MedalMatchesViewModel.cs b/src/OpenSpartan.Workshop/ViewModels/MedalMatchesViewModel.cs index 1e860c6..11eec90 100644 --- a/src/OpenSpartan.Workshop/ViewModels/MedalMatchesViewModel.cs +++ b/src/OpenSpartan.Workshop/ViewModels/MedalMatchesViewModel.cs @@ -1,4 +1,4 @@ -using CommunityToolkit.WinUI; +using CommunityToolkit.WinUI.Collections; using Den.Dev.Orion.Models.HaloInfinite; using OpenSpartan.Workshop.Core; using OpenSpartan.Workshop.Data; @@ -10,15 +10,15 @@ namespace OpenSpartan.Workshop.ViewModels { internal class MedalMatchesViewModel : Observable, IDisposable { - public static MedalMatchesViewModel Instance { get; } = new MedalMatchesViewModel(); + public static MedalMatchesViewModel? Instance { get; } = new MedalMatchesViewModel(); - private MetadataLoadingState _matchLoadingState; - private IncrementalLoadingCollection _matchList; - private Medal _medal; + private MetadataLoadingState? _matchLoadingState; + private IncrementalLoadingCollection? _matchList; + private Medal? _medal; - public RelayCommand NavigateCommand { get; } + public RelayCommand? NavigateCommand { get; } - public event EventHandler NavigationRequested; + public event EventHandler? NavigationRequested; public MedalMatchesViewModel() { @@ -40,7 +40,7 @@ public string MatchLoadingString } } - public MetadataLoadingState MatchLoadingState + public MetadataLoadingState? MatchLoadingState { get => _matchLoadingState; set @@ -54,7 +54,7 @@ public MetadataLoadingState MatchLoadingState } } - public Medal Medal + public Medal? Medal { get => _medal; set @@ -66,7 +66,7 @@ public Medal Medal } } } - public IncrementalLoadingCollection MatchList + public IncrementalLoadingCollection? MatchList { get => _matchList; set @@ -79,7 +79,7 @@ public IncrementalLoadingCollection MatchL } } - public void NotifyPropertyChanged([CallerMemberName] string propertyName = null) + public void NotifyPropertyChanged([CallerMemberName] string? propertyName = null) { OnPropertyChanged(propertyName); } @@ -100,10 +100,7 @@ protected virtual void Dispose(bool disposing) private void CleanupManagedResources() { - if (this.MatchList != null) - { - this.MatchList.Clear(); - } + this.MatchList?.Clear(); this.MatchList = null; this.Medal = null; diff --git a/src/OpenSpartan.Workshop/ViewModels/MedalsViewModel.cs b/src/OpenSpartan.Workshop/ViewModels/MedalsViewModel.cs index e815787..ba7d402 100644 --- a/src/OpenSpartan.Workshop/ViewModels/MedalsViewModel.cs +++ b/src/OpenSpartan.Workshop/ViewModels/MedalsViewModel.cs @@ -40,7 +40,7 @@ private void NavigateToAnotherView(long parameter) NavigationRequested?.Invoke(this, parameter); } - public void NotifyPropertyChanged([CallerMemberName] string propertyName = null) + public void NotifyPropertyChanged([CallerMemberName] string? propertyName = null) { OnPropertyChanged(propertyName); } diff --git a/src/OpenSpartan.Workshop/ViewModels/RankedViewModel.cs b/src/OpenSpartan.Workshop/ViewModels/RankedViewModel.cs new file mode 100644 index 0000000..d98d49f --- /dev/null +++ b/src/OpenSpartan.Workshop/ViewModels/RankedViewModel.cs @@ -0,0 +1,51 @@ +using OpenSpartan.Workshop.Core; +using OpenSpartan.Workshop.Models; +using System.Collections.ObjectModel; +using System.Runtime.CompilerServices; + +namespace OpenSpartan.Workshop.ViewModels +{ + internal class RankedViewModel : Observable + { + private MetadataLoadingState _rankedLoadingState; + private ObservableCollection _playlists; + + public static RankedViewModel Instance { get; } = new RankedViewModel(); + + public RankedViewModel() + { + Playlists = []; + } + + public ObservableCollection Playlists + { + get => _playlists; + set + { + if (_playlists != value) + { + _playlists = value; + NotifyPropertyChanged(); + } + } + } + + public MetadataLoadingState RankedLoadingState + { + get => _rankedLoadingState; + set + { + if (_rankedLoadingState != value) + { + _rankedLoadingState = value; + NotifyPropertyChanged(); + } + } + } + + public void NotifyPropertyChanged([CallerMemberName] string propertyName = null) + { + OnPropertyChanged(propertyName); + } + } +} diff --git a/src/OpenSpartan.Workshop/ViewModels/SeasonCalendarViewModel.cs b/src/OpenSpartan.Workshop/ViewModels/SeasonCalendarViewModel.cs new file mode 100644 index 0000000..a41adb4 --- /dev/null +++ b/src/OpenSpartan.Workshop/ViewModels/SeasonCalendarViewModel.cs @@ -0,0 +1,51 @@ +using OpenSpartan.Workshop.Core; +using OpenSpartan.Workshop.Models; +using System.Collections.ObjectModel; +using System.Runtime.CompilerServices; + +namespace OpenSpartan.Workshop.ViewModels +{ + internal class SeasonCalendarViewModel : Observable + { + private MetadataLoadingState _calendarLoadingState; + private ObservableCollection _seasonDays; + + public static SeasonCalendarViewModel Instance { get; } = new SeasonCalendarViewModel(); + + private SeasonCalendarViewModel() + { + SeasonDays = []; + } + + public MetadataLoadingState CalendarLoadingState + { + get => _calendarLoadingState; + set + { + if (_calendarLoadingState != value) + { + _calendarLoadingState = value; + NotifyPropertyChanged(); + } + } + } + + public ObservableCollection SeasonDays + { + get => _seasonDays; + set + { + if (_seasonDays != value) + { + _seasonDays = value; + NotifyPropertyChanged(); + } + } + } + + public void NotifyPropertyChanged([CallerMemberName] string propertyName = null) + { + OnPropertyChanged(propertyName); + } + } +} diff --git a/src/OpenSpartan.Workshop/ViewModels/SettingsViewModel.cs b/src/OpenSpartan.Workshop/ViewModels/SettingsViewModel.cs index d8e8c1e..6aa2846 100644 --- a/src/OpenSpartan.Workshop/ViewModels/SettingsViewModel.cs +++ b/src/OpenSpartan.Workshop/ViewModels/SettingsViewModel.cs @@ -12,6 +12,23 @@ internal sealed class SettingsViewModel : Observable public static string Xuid => HomeViewModel.Instance.Xuid; + public bool EnableLooseMatchSearch + { + get + { + return Settings.EnableLooseMatchSearch; + } + set + { + if (Settings.EnableLooseMatchSearch != value) + { + Settings.EnableLooseMatchSearch = value; + SettingsManager.StoreSettings(Settings); + NotifyPropertyChanged(); + } + } + } + public bool SyncSettings { get @@ -63,6 +80,74 @@ public bool EnableLogging } } + public bool UseBroker + { + get + { + return Settings.UseBroker; + } + set + { + if (Settings.UseBroker != value) + { + Settings.UseBroker = value; + SettingsManager.StoreSettings(Settings); + NotifyPropertyChanged(); + } + } + } + + public string Sandbox + { + get + { + return Settings.Sandbox; + } + set + { + if (Settings.Sandbox != value) + { + Settings.Sandbox = value; + SettingsManager.StoreSettings(Settings); + NotifyPropertyChanged(); + } + } + } + + public string Build + { + get + { + return Settings.Build; + } + set + { + if (Settings.Build != value) + { + Settings.Build = value; + SettingsManager.StoreSettings(Settings); + NotifyPropertyChanged(); + } + } + } + + public bool UseObanClearance + { + get + { + return Settings.UseObanClearance; + } + set + { + if (Settings.UseObanClearance != value) + { + Settings.UseObanClearance = value; + SettingsManager.StoreSettings(Settings); + NotifyPropertyChanged(); + } + } + } + public WorkshopSettings Settings { get => _settings; @@ -71,6 +156,7 @@ public WorkshopSettings Settings if (_settings != value) { _settings = value; + SettingsManager.StoreSettings(Settings); NotifyPropertyChanged(); } } diff --git a/src/OpenSpartan.Workshop/ViewModels/SplashScreenViewModel.cs b/src/OpenSpartan.Workshop/ViewModels/SplashScreenViewModel.cs index ae8d31c..c098067 100644 --- a/src/OpenSpartan.Workshop/ViewModels/SplashScreenViewModel.cs +++ b/src/OpenSpartan.Workshop/ViewModels/SplashScreenViewModel.cs @@ -27,7 +27,7 @@ public bool IsBlocking private bool _isBlocking; - public void NotifyPropertyChanged([CallerMemberName] string propertyName = null) + public void NotifyPropertyChanged([CallerMemberName] string? propertyName = null) { OnPropertyChanged(propertyName); } diff --git a/src/OpenSpartan.Workshop/Views/BattlePassDetailView.xaml b/src/OpenSpartan.Workshop/Views/BattlePassDetailView.xaml index 074fefe..53bfdee 100644 --- a/src/OpenSpartan.Workshop/Views/BattlePassDetailView.xaml +++ b/src/OpenSpartan.Workshop/Views/BattlePassDetailView.xaml @@ -5,6 +5,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:localcontrols="using:OpenSpartan.Workshop.Controls" mc:Ignorable="d" NavigationCacheMode="Enabled" Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"> @@ -14,22 +15,41 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - + @@ -42,6 +62,28 @@ + + + + + + + + + + + + + + + + + diff --git a/src/OpenSpartan.Workshop/Views/BattlePassDetailView.xaml.cs b/src/OpenSpartan.Workshop/Views/BattlePassDetailView.xaml.cs index 1126c0b..e5d565d 100644 --- a/src/OpenSpartan.Workshop/Views/BattlePassDetailView.xaml.cs +++ b/src/OpenSpartan.Workshop/Views/BattlePassDetailView.xaml.cs @@ -1,5 +1,7 @@ +using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Navigation; +using OpenSpartan.Workshop.Core; using OpenSpartan.Workshop.ViewModels; using System.Linq; @@ -18,5 +20,14 @@ protected override void OnNavigatedTo(NavigationEventArgs e) base.OnNavigatedTo(e); } + + private void BattlePassItem_Tapped(object sender, Microsoft.UI.Xaml.Input.TappedRoutedEventArgs e) + { + var teachingTip = UserInterface.FindChildElement((DependencyObject)sender); + if (teachingTip != null) + { + teachingTip.IsOpen = true; + } + } } } diff --git a/src/OpenSpartan.Workshop/Views/BattlePassView.xaml b/src/OpenSpartan.Workshop/Views/BattlePassView.xaml index ee87824..ba842c6 100644 --- a/src/OpenSpartan.Workshop/Views/BattlePassView.xaml +++ b/src/OpenSpartan.Workshop/Views/BattlePassView.xaml @@ -15,7 +15,10 @@ - + + + + @@ -39,14 +42,29 @@ - + + + + + + + + + + + + + + + + - + diff --git a/src/OpenSpartan.Workshop/Views/BattlePassView.xaml.cs b/src/OpenSpartan.Workshop/Views/BattlePassView.xaml.cs index cf7a9d7..573ff7b 100644 --- a/src/OpenSpartan.Workshop/Views/BattlePassView.xaml.cs +++ b/src/OpenSpartan.Workshop/Views/BattlePassView.xaml.cs @@ -13,30 +13,58 @@ public BattlePassView() InitializeComponent(); } - private void NavView_Navigate(Type navPageType, NavigationTransitionInfo transitionInfo, string argument) + private void NavigateBattlePassView(Type navPageType, NavigationTransitionInfo transitionInfo, string argument) { // Only navigate if the selected page isn't currently loaded. if (navPageType is not null) { - ContentFrame.Navigate(navPageType, argument, transitionInfo); + BattlePassContentFrame.Navigate(navPageType, argument, transitionInfo); BattlePassViewModel.Instance.CurrentlySelectedBattlepass = argument; } } + private void NavigateEventView(Type navPageType, NavigationTransitionInfo transitionInfo, string argument) + { + // Only navigate if the selected page isn't currently loaded. + if (navPageType is not null) + { + EventContentFrame.Navigate(navPageType, argument, transitionInfo); + BattlePassViewModel.Instance.CurrentlySelectedEvent = argument; + } + } + private void nvBattlePassDetails_ItemInvoked(NavigationView sender, NavigationViewItemInvokedEventArgs args) { if (args.InvokedItem is string) { if (args.InvokedItemContainer != null && ((OperationCompoundModel)nvBattlePassDetails.SelectedItem).RewardTrack.RewardTrackPath != BattlePassViewModel.Instance.CurrentlySelectedBattlepass) { - NavView_Navigate(typeof(BattlePassDetailView), args.RecommendedNavigationTransitionInfo, args.InvokedItemContainer.Tag.ToString()); + NavigateBattlePassView(typeof(BattlePassDetailView), args.RecommendedNavigationTransitionInfo, args.InvokedItemContainer.Tag.ToString()); } } else if (args.InvokedItem is OperationCompoundModel) { if (((OperationCompoundModel)nvBattlePassDetails.SelectedItem).RewardTrack.RewardTrackPath != BattlePassViewModel.Instance.CurrentlySelectedBattlepass) { - NavView_Navigate(typeof(BattlePassDetailView), args.RecommendedNavigationTransitionInfo, ((OperationCompoundModel)args.InvokedItem).RewardTrack.RewardTrackPath); + NavigateBattlePassView(typeof(BattlePassDetailView), args.RecommendedNavigationTransitionInfo, ((OperationCompoundModel)args.InvokedItem).RewardTrack.RewardTrackPath); + } + } + } + + private void nvEventDetails_ItemInvoked(NavigationView sender, NavigationViewItemInvokedEventArgs args) + { + if (args.InvokedItem is string) + { + if (args.InvokedItemContainer != null && ((OperationCompoundModel)nvEventDetails.SelectedItem).RewardTrack.RewardTrackPath != BattlePassViewModel.Instance.CurrentlySelectedEvent) + { + NavigateEventView(typeof(EventDetailView), args.RecommendedNavigationTransitionInfo, args.InvokedItemContainer.Tag.ToString()); + } + } + else if (args.InvokedItem is OperationCompoundModel) + { + if (((OperationCompoundModel)nvEventDetails.SelectedItem).RewardTrack.RewardTrackPath != BattlePassViewModel.Instance.CurrentlySelectedEvent) + { + NavigateEventView(typeof(EventDetailView), args.RecommendedNavigationTransitionInfo, ((OperationCompoundModel)args.InvokedItem).RewardTrack.RewardTrackPath); } } } diff --git a/src/OpenSpartan.Workshop/Views/EventDetailView.xaml b/src/OpenSpartan.Workshop/Views/EventDetailView.xaml new file mode 100644 index 0000000..52c21ac --- /dev/null +++ b/src/OpenSpartan.Workshop/Views/EventDetailView.xaml @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OpenSpartan.Workshop/Views/EventDetailView.xaml.cs b/src/OpenSpartan.Workshop/Views/EventDetailView.xaml.cs new file mode 100644 index 0000000..bb3719b --- /dev/null +++ b/src/OpenSpartan.Workshop/Views/EventDetailView.xaml.cs @@ -0,0 +1,39 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Navigation; +using OpenSpartan.Workshop.Core; +using OpenSpartan.Workshop.ViewModels; +using System.Linq; + +// To learn more about WinUI, the WinUI project structure, +// and more about our project templates, see: http://aka.ms/winui-project-info. + +namespace OpenSpartan.Workshop.Views +{ + /// + /// An empty page that can be used on its own or navigated to within a Frame. + /// + public sealed partial class EventDetailView : Page + { + public EventDetailView() + { + this.InitializeComponent(); + } + + protected override void OnNavigatedTo(NavigationEventArgs e) + { + DataContext = (from c in BattlePassViewModel.Instance.Events where c.RewardTrack.RewardTrackPath == e.Parameter.ToString() select c).FirstOrDefault(); + + base.OnNavigatedTo(e); + } + + private void EventItem_Tapped(object sender, Microsoft.UI.Xaml.Input.TappedRoutedEventArgs e) + { + var teachingTip = UserInterface.FindChildElement((DependencyObject)sender); + if (teachingTip != null) + { + teachingTip.IsOpen = true; + } + } + } +} diff --git a/src/OpenSpartan.Workshop/Views/ExchangeView.xaml b/src/OpenSpartan.Workshop/Views/ExchangeView.xaml new file mode 100644 index 0000000..3f1d6a7 --- /dev/null +++ b/src/OpenSpartan.Workshop/Views/ExchangeView.xaml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OpenSpartan.Workshop/Views/ExchangeView.xaml.cs b/src/OpenSpartan.Workshop/Views/ExchangeView.xaml.cs new file mode 100644 index 0000000..614f741 --- /dev/null +++ b/src/OpenSpartan.Workshop/Views/ExchangeView.xaml.cs @@ -0,0 +1,40 @@ +using CommunityToolkit.WinUI; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using OpenSpartan.Workshop.Core; +using OpenSpartan.Workshop.Models; +using OpenSpartan.Workshop.ViewModels; + +namespace OpenSpartan.Workshop.Views +{ + public sealed partial class ExchangeView : Page + { + public ExchangeView() + { + this.InitializeComponent(); + } + + private void ExchangeItem_Tapped(object sender, TappedRoutedEventArgs e) + { + var teachingTip = UserInterface.FindChildElement((DependencyObject)sender); + if (teachingTip != null) + { + teachingTip.IsOpen = true; + } + } + + private async void btnRefreshExchange_Click(object sender, RoutedEventArgs e) + { + var matchRecordsOutcome = await UserContextManager.PopulateExchangeData(); + + if (matchRecordsOutcome) + { + await UserContextManager.DispatcherWindow.DispatcherQueue.EnqueueAsync(() => + { + ExchangeViewModel.Instance.ExchangeLoadingState = MetadataLoadingState.Completed; + }); + } + } + } +} diff --git a/src/OpenSpartan.Workshop/Views/HomeView.xaml b/src/OpenSpartan.Workshop/Views/HomeView.xaml index 38197a7..f3c753a 100644 --- a/src/OpenSpartan.Workshop/Views/HomeView.xaml +++ b/src/OpenSpartan.Workshop/Views/HomeView.xaml @@ -25,7 +25,7 @@ - + @@ -116,42 +116,42 @@ - - - - - + + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - - - - - - - + + - + + + + + + + + + + + + diff --git a/src/OpenSpartan.Workshop/Views/HomeView.xaml.cs b/src/OpenSpartan.Workshop/Views/HomeView.xaml.cs index 5d4f6e0..33ffe35 100644 --- a/src/OpenSpartan.Workshop/Views/HomeView.xaml.cs +++ b/src/OpenSpartan.Workshop/Views/HomeView.xaml.cs @@ -1,5 +1,4 @@ using Microsoft.UI.Xaml.Controls; -using NLog; using OpenSpartan.Workshop.Core; using OpenSpartan.Workshop.ViewModels; using System; @@ -9,8 +8,6 @@ namespace OpenSpartan.Workshop.Views { public sealed partial class HomeView : Page { - private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); - public HomeView() { InitializeComponent(); @@ -24,7 +21,7 @@ private async void btnOpenHaloWaypoint_Click(object sender, Microsoft.UI.Xaml.Ro if (!success) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error("Could not open the profile on Halo Waypoint."); + LogEngine.Log("Could not open the profile on Halo Waypoint.", Models.LogSeverity.Error); } } diff --git a/src/OpenSpartan.Workshop/Views/RankedView.xaml b/src/OpenSpartan.Workshop/Views/RankedView.xaml new file mode 100644 index 0000000..359cdd9 --- /dev/null +++ b/src/OpenSpartan.Workshop/Views/RankedView.xaml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OpenSpartan.Workshop/Views/RankedView.xaml.cs b/src/OpenSpartan.Workshop/Views/RankedView.xaml.cs new file mode 100644 index 0000000..c5aadc4 --- /dev/null +++ b/src/OpenSpartan.Workshop/Views/RankedView.xaml.cs @@ -0,0 +1,18 @@ +using Microsoft.UI.Xaml.Controls; +using OpenSpartan.Workshop.Core; + +namespace OpenSpartan.Workshop.Views +{ + public sealed partial class RankedView : Page + { + public RankedView() + { + this.InitializeComponent(); + } + + private async void btnRankedRefresh_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) + { + await UserContextManager.PopulateServiceRecordData(); + } + } +} diff --git a/src/OpenSpartan.Workshop/Views/SeasonCalendarView.xaml b/src/OpenSpartan.Workshop/Views/SeasonCalendarView.xaml new file mode 100644 index 0000000..9609799 --- /dev/null +++ b/src/OpenSpartan.Workshop/Views/SeasonCalendarView.xaml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OpenSpartan.Workshop/Views/SeasonCalendarView.xaml.cs b/src/OpenSpartan.Workshop/Views/SeasonCalendarView.xaml.cs new file mode 100644 index 0000000..7867e4f --- /dev/null +++ b/src/OpenSpartan.Workshop/Views/SeasonCalendarView.xaml.cs @@ -0,0 +1,12 @@ +using Microsoft.UI.Xaml.Controls; + +namespace OpenSpartan.Workshop.Views +{ + public sealed partial class SeasonCalendarView : Page + { + public SeasonCalendarView() + { + this.InitializeComponent(); + } + } +} diff --git a/src/OpenSpartan.Workshop/Views/SettingsView.xaml b/src/OpenSpartan.Workshop/Views/SettingsView.xaml index 5f051c3..6c013ef 100644 --- a/src/OpenSpartan.Workshop/Views/SettingsView.xaml +++ b/src/OpenSpartan.Workshop/Views/SettingsView.xaml @@ -8,6 +8,7 @@ mc:Ignorable="d" NavigationCacheMode="Enabled" xmlns:viewmodels="using:OpenSpartan.Workshop.ViewModels" + xmlns:winui="using:CommunityToolkit.WinUI.Controls" DataContext="{x:Bind viewmodels:SettingsViewModel.Instance}" Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"> @@ -29,91 +30,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - OpenSpartan Workshop - - Version: - - Report issues or request features on GitHub. Built with ❤️ by Den Delimarsky as an unofficial Halo Infinite companion app. - - - - - - - - - - - - - - - - - - - Gamertag: - - - XUID: - - - - - - - - - - - - - - - - - - Logging is enabled - Logging is disabled - - - - - - - - - - Sync with OpenSpartan Workshop API settings - Set directly - - - - - - - - - - - - Release: - - - - - - - + This application is not an official Halo Infinite client. Use at your own risk. The author of this application is not liable for any damages, including the termination or banning of Xbox Live/Microsoft accounts. diff --git a/src/OpenSpartan.Workshop/Views/SettingsView.xaml.cs b/src/OpenSpartan.Workshop/Views/SettingsView.xaml.cs index 5ccb174..f811895 100644 --- a/src/OpenSpartan.Workshop/Views/SettingsView.xaml.cs +++ b/src/OpenSpartan.Workshop/Views/SettingsView.xaml.cs @@ -6,14 +6,11 @@ using System; using System.Diagnostics; using System.IO; -using System.Windows.Forms; namespace OpenSpartan.Workshop.Views { public sealed partial class SettingsView : Page { - private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); - public SettingsView() { InitializeComponent(); @@ -21,9 +18,21 @@ public SettingsView() private async void btnLogOut_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - var result = MessageBox.Show("Are you sure you want to log out?", "OpenSpartan Workshop", MessageBoxButtons.YesNo); + ContentDialog deleteFileDialog = new ContentDialog + { + Title = "Log out", + Content = "Are you sure you want to log out?", + PrimaryButtonText = "Yes", + CloseButtonText = "No", + DefaultButton = ContentDialogButton.Close, + XamlRoot = this.Content.XamlRoot + }; + + ContentDialogResult result = await deleteFileDialog.ShowAsync(); - if (result == DialogResult.Yes) + // Delete the file if the user clicked the primary button. + /// Otherwise, do nothing. + if (result == ContentDialogResult.Primary) { try { @@ -50,7 +59,7 @@ await UserContextManager.DispatcherWindow.DispatcherQueue.EnqueueAsync(() => } catch (Exception ex) { - if (SettingsViewModel.Instance.EnableLogging) Logger.Error($"Could not log out by deleting the credential cache file. {ex.Message}"); + LogEngine.Log($"Could not log out by deleting the credential cache file. {ex.Message}", Models.LogSeverity.Error); } } }