diff --git a/CommandLine/Program.cs b/CommandLine/Program.cs index 79fe26f8..4ef2b7cd 100644 --- a/CommandLine/Program.cs +++ b/CommandLine/Program.cs @@ -105,7 +105,7 @@ public int OnExecute() if (Flags == null) { - Flags = configuration.Flags; + Flags = configuration.SerializeFlags(); } logger.Info($"Flags: {Flags}"); logger.Info($"Rom: {Rom}"); diff --git a/CommandLine/Sample.json b/CommandLine/Sample.json index 0887e5cb..d6327e65 100644 --- a/CommandLine/Sample.json +++ b/CommandLine/Sample.json @@ -103,6 +103,7 @@ "ShuffleDripperEnemy": false, "ShuffleEncounters": false, "ShuffleEnemyHP": false, +"ShuffleBossHP": "VANILLA", "ShuffleGP": false, "ShuffleItemDropFrequency": true, "ShuffleLifeExperience": false, diff --git a/CoreSourceGenerator/CoreSourceGenerator.csproj b/CoreSourceGenerator/CoreSourceGenerator.csproj index 9295af99..e654d119 100644 --- a/CoreSourceGenerator/CoreSourceGenerator.csproj +++ b/CoreSourceGenerator/CoreSourceGenerator.csproj @@ -25,7 +25,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CoreSourceGenerator/FlagsSerializeGenerator.cs b/CoreSourceGenerator/FlagsSerializeGenerator.cs index 1b64b1e2..2ce01281 100644 --- a/CoreSourceGenerator/FlagsSerializeGenerator.cs +++ b/CoreSourceGenerator/FlagsSerializeGenerator.cs @@ -292,7 +292,7 @@ private static void GenerateReactiveProperty(StringBuilder sb, ReactiveFieldInfo sb.AppendLine($"{indent} {{"); sb.AppendLine($"{indent} {field.FieldName} = value;"); sb.AppendLine($"{indent} OnPropertyChanged(nameof({field.PropertyName}));"); - sb.AppendLine($"{indent} OnPropertyChanged(nameof(Flags));"); + sb.AppendLine($"{indent} OnPropertyChanged(\"Flags\");"); sb.AppendLine($"{indent} }}"); sb.AppendLine($"{indent} }}"); sb.AppendLine($"{indent} }}{defaultValue}"); diff --git a/CrossPlatformUI.Desktop/LocalFilePersistenceService.cs b/CrossPlatformUI.Desktop/LocalFilePersistenceService.cs index bda9c2d7..cbf172c8 100644 --- a/CrossPlatformUI.Desktop/LocalFilePersistenceService.cs +++ b/CrossPlatformUI.Desktop/LocalFilePersistenceService.cs @@ -13,8 +13,6 @@ namespace CrossPlatformUI.Desktop; [RequiresUnreferencedCode("Newtonsoft.Json uses reflection")] public class LocalFilePersistenceService : ISuspendSyncService // : ISuspensionDriver { - // TODO put this in appdata - public const string SettingsFilename = "Settings.json"; public string? SettingsPath; public LocalFilePersistenceService() @@ -36,7 +34,7 @@ public LocalFilePersistenceService() public object? LoadState() { - var data = File.ReadAllText(SettingsFilename); + var data = File.ReadAllText(App.SETTINGS_FILENAME); return JsonConvert.DeserializeObject(data, serializerSettings); } @@ -46,7 +44,7 @@ public void SaveState(object state) var next = JObject.Parse(json); try { - var settings = File.ReadAllText(SettingsFilename); + var settings = File.ReadAllText(App.SETTINGS_FILENAME); var orig = JObject.Parse(settings); orig.Merge(next, new JsonMergeSettings { @@ -57,7 +55,7 @@ public void SaveState(object state) } catch (Exception e) when (e is JsonException or IOException) { } - using var file = File.CreateText(SettingsFilename); + using var file = File.CreateText(App.SETTINGS_FILENAME); using var writer = new JsonTextWriter(file); next.WriteTo(writer); } @@ -66,7 +64,7 @@ public void InvalidateState() { try { - File.Delete(SettingsFilename); + File.Delete(App.SETTINGS_FILENAME); } catch (IOException) {} } @@ -74,4 +72,4 @@ public Task> ListLocalFiles(string path) { return Task.FromResult(Directory.GetFiles(path).AsEnumerable()); } -} \ No newline at end of file +} diff --git a/CrossPlatformUI/App.axaml.cs b/CrossPlatformUI/App.axaml.cs index a85d863a..57a62965 100644 --- a/CrossPlatformUI/App.axaml.cs +++ b/CrossPlatformUI/App.axaml.cs @@ -13,6 +13,7 @@ using Avalonia.Markup.Xaml; using Material.Styles.Assists; using Microsoft.Extensions.DependencyInjection; +using Z2Randomizer.RandomizerCore; using CrossPlatformUI.Services; using CrossPlatformUI.ViewModels; using CrossPlatformUI.Views; @@ -57,11 +58,10 @@ public override void Initialize() public static string Version = ""; public static string Title = ""; + public const string SETTINGS_FILENAME = "Settings_v5_1.json"; public static TopLevel? TopLevel { get; private set; } - // public static MainViewModel? Main { get; set; } - private MainViewModel? main; public override void OnFrameworkInitializationCompleted() @@ -73,7 +73,7 @@ public override void OnFrameworkInitializationCompleted() var files = FileSystemService!; try { - var json = files.OpenFileSync(IFileSystemService.RandomizerPath.Settings, "Settings.json"); + var json = files.OpenFileSync(IFileSystemService.RandomizerPath.Settings, SETTINGS_FILENAME); main = JsonSerializer.Deserialize(json, new SerializationContext(true).MainViewModel)!; } catch (System.IO.FileNotFoundException) { /* No settings file exists */ } @@ -153,7 +153,7 @@ private Task PersistStateInternal() { var files = Current?.Services?.GetService()!; var json = JsonSerializer.Serialize(main!, SerializationContext.Default.MainViewModel); - await files.SaveFile(IFileSystemService.RandomizerPath.Settings, "Settings.json", json); + await files.SaveFile(IFileSystemService.RandomizerPath.Settings, SETTINGS_FILENAME, json); }); } @@ -177,78 +177,116 @@ private Task PersistStateInternal() PreferredObjectCreationHandling = JsonObjectCreationHandling.Populate )] [JsonSerializable(typeof(MainViewModel))] +[JsonSerializable(typeof(RandomizerConfiguration))] public partial class SerializationContext : JsonSerializerContext { public SerializationContext(bool _) // added an argument to avoid constructors colliding - : base(CreateOptions()) + : base(InitSafeOptions()) { } - private static JsonSerializerOptions CreateOptions() + /// modifies original context - can't be called after init + private static JsonSerializerOptions InitSafeOptions() { var options = Default.GeneratedSerializerOptions!; + options.Converters.Add(new SafeStringEnumConverterFactory()); + return options; + } - var enumNamespacePrefix = "Z2Randomizer"; + /// returns a new options copy with safe serialization + public static JsonSerializerOptions CreateSafeOptions() + { + var options = new JsonSerializerOptions + { + TypeInfoResolver = SerializationContext.Default + }; + options.Converters.Add(new SafeStringEnumConverterFactory()); + return options; + } +} - foreach (var enumType in AppDomain.CurrentDomain.GetAssemblies() - .Where(a => !a.IsDynamic) - .SelectMany(a => - { -#pragma warning disable IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code - try { return a.GetTypes(); } -#pragma warning restore IL2026 - catch { return Enumerable.Empty(); } - }) - .Where(t => - t.IsEnum && - t.IsPublic && - !t.IsGenericTypeDefinition && - t.Namespace != null && t.Namespace.StartsWith(enumNamespacePrefix) - )) +public sealed class SafeStringEnumConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(Type typeToConvert) + => typeToConvert.IsEnum; + + [RequiresUnreferencedCode("Uses reflection to construct generic enum converters at runtime.")] + [SuppressMessage("Trimming", "IL2046:'RequiresUnreferencedCodeAttribute' annotations must match across all interface implementations or overrides.", Justification = " Justification = \"Enum converters are created dynamically; enum metadata is preserved.\")]")] + public override JsonConverter CreateConverter( + Type typeToConvert, + JsonSerializerOptions options) + { + var converterType = typeof(SafeStringEnumConverter<>) + .MakeGenericType(typeToConvert); + + return (JsonConverter)Activator.CreateInstance(converterType)!; + } +} + +/// Custom StringEnumConverter that returns default instead of failing for unknown values +public sealed class SafeStringEnumConverter : JsonConverter where T : struct, Enum +{ + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + try { - try + if (reader.TokenType == JsonTokenType.String) { -#pragma warning disable IL2076 // 'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations. - var converterType = typeof(SafeStringEnumConverter<>).MakeGenericType(enumType); -#pragma warning restore IL2076 - var converter = (JsonConverter)Activator.CreateInstance(converterType)!; - options.Converters.Add(converter); + var value = reader.GetString(); + + if (!string.IsNullOrEmpty(value) && + Enum.TryParse(value, ignoreCase: true, out var parsed)) + { + return parsed; + } } - catch + else if (reader.TokenType == JsonTokenType.Number) { - // we try our best to add the custom parsing, but proceed regardless + if (reader.TryGetInt32(out var raw) && Enum.IsDefined(typeof(T), raw)) + { + return (T)Enum.ToObject(typeof(T), raw); + } } } + catch // we prefer returning the default over failing + { + } - return options; + return default; } -} -/// Custom StringEnumConverter that returns default instead of failing for unknown values -public class SafeStringEnumConverter : JsonConverter where T : struct, Enum -{ - public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } + + public override T ReadAsPropertyName( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) { - if (reader.TokenType == JsonTokenType.String) + try { var value = reader.GetString(); - if (Enum.TryParse(value, ignoreCase: true, out var result)) + + if (!string.IsNullOrEmpty(value) && + Enum.TryParse(value, ignoreCase: true, out var parsed)) { - return result; + return parsed; } } - else if (reader.TokenType == JsonTokenType.Number && - reader.TryGetInt32(out var intValue) && - Enum.IsDefined(typeof(T), intValue)) + catch // we prefer returning the default over failing { - return (T)Enum.ToObject(typeof(T), intValue); } return default; } - public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + public override void WriteAsPropertyName( + Utf8JsonWriter writer, + T value, + JsonSerializerOptions options) { - writer.WriteStringValue(value.ToString()); + writer.WritePropertyName(value.ToString()); } } diff --git a/CrossPlatformUI/CrossPlatformUI.csproj b/CrossPlatformUI/CrossPlatformUI.csproj index d81fadad..5f4782a1 100644 --- a/CrossPlatformUI/CrossPlatformUI.csproj +++ b/CrossPlatformUI/CrossPlatformUI.csproj @@ -36,13 +36,13 @@ - + - + all - + @@ -51,10 +51,10 @@ - - - - + + + + @@ -84,10 +84,7 @@ - + diff --git a/CrossPlatformUI/Lang/Resources.resx b/CrossPlatformUI/Lang/Resources.resx index 2da72535..39fbc4d9 100644 --- a/CrossPlatformUI/Lang/Resources.resx +++ b/CrossPlatformUI/Lang/Resources.resx @@ -217,6 +217,11 @@ taking damage. Play at your own risk. If selected, each enemy type will have between 50% and 150% of its vanilla HP value. + + +Choose how boss HP is randomized. HP will always be capped at 255. + +Great Palace bosses are not included. Randomly reassigns which enemies steal EXP. The number of enemies that steal EXP in each @@ -235,13 +240,19 @@ immune in each group is the same as the number in that group that are immune in in the dark to change. -Controls how much experience enemies drop. The possible experience values for an enemy are +Controls how much experience enemies give. + +The possible experience values for an enemy are: + 0, 2, 3, 5, 10, 15, 20, 30, 50, 70, 100, 150, 200, 300, 500, 700, 1000. - • Vanilla: Same as the base game - • Low: Each enemy gives a random XP level between -3 and +1 levels of vanilla. - • Average: Each enemy gives a random XP level between -2 and +2 levels of vanilla. - • High: Each enemy gives a random XP level between -1 and +3 levels of vanilla. +When XP is randomized, the enemy's XP value shifts left or right in this list by the rolled number of steps. + +For example, a Blue Stalfos gives 50 XP in the vanilla game. If the XP is rolled -2 to +2 levels, +the minimum XP it could give is 20 and the maximum is 100. + +Also note that XP values are randomized per enemy table and can differ in a single seed. For example +Red Iron Knuckle XP in P1/P2/P5 could roll down while Red Iron Knuckle XP in P3/P4/P6 rolls up. When enabled, helpful hints are scattered throughout the world. These hints are provided diff --git a/CrossPlatformUI/Presets/FullShufflePreset.cs b/CrossPlatformUI/Presets/FullShufflePreset.cs index c6eb9b6d..4a3e4bf8 100644 --- a/CrossPlatformUI/Presets/FullShufflePreset.cs +++ b/CrossPlatformUI/Presets/FullShufflePreset.cs @@ -95,6 +95,7 @@ public static class FullShufflePreset GeneratorsAlwaysMatch = true, ShuffleEnemyHP = true, + ShuffleBossHP = EnemyLifeOption.MEDIUM, ShuffleXPStealers = true, ShuffleXPStolenAmount = true, ShuffleSwordImmunity = true, diff --git a/CrossPlatformUI/Presets/HardmodePreset.cs b/CrossPlatformUI/Presets/HardmodePreset.cs index 349d1d09..b7474894 100644 --- a/CrossPlatformUI/Presets/HardmodePreset.cs +++ b/CrossPlatformUI/Presets/HardmodePreset.cs @@ -95,6 +95,7 @@ public static class HardmodePreset GeneratorsAlwaysMatch = true, ShuffleEnemyHP = true, + ShuffleBossHP = EnemyLifeOption.VANILLA, ShuffleXPStealers = true, ShuffleXPStolenAmount = true, ShuffleSwordImmunity = true, diff --git a/CrossPlatformUI/Presets/MaxRandoPreset.cs b/CrossPlatformUI/Presets/MaxRandoPreset.cs index 6e4212ad..a2488c60 100644 --- a/CrossPlatformUI/Presets/MaxRandoPreset.cs +++ b/CrossPlatformUI/Presets/MaxRandoPreset.cs @@ -95,6 +95,7 @@ public static class MaxRandoPreset GeneratorsAlwaysMatch = true, ShuffleEnemyHP = true, + ShuffleBossHP = EnemyLifeOption.MEDIUM, ShuffleXPStealers = true, ShuffleXPStolenAmount = true, ShuffleSwordImmunity = true, diff --git a/CrossPlatformUI/Presets/NormalPreset.cs b/CrossPlatformUI/Presets/NormalPreset.cs index a0d09c92..e0f74f61 100644 --- a/CrossPlatformUI/Presets/NormalPreset.cs +++ b/CrossPlatformUI/Presets/NormalPreset.cs @@ -95,6 +95,7 @@ public static class NormalPreset GeneratorsAlwaysMatch = true, ShuffleEnemyHP = true, + ShuffleBossHP = EnemyLifeOption.MEDIUM, ShuffleXPStealers = true, ShuffleXPStolenAmount = true, ShuffleSwordImmunity = true, diff --git a/CrossPlatformUI/Presets/RandomPercentPreset.cs b/CrossPlatformUI/Presets/RandomPercentPreset.cs index 9a1844a0..900976a7 100644 --- a/CrossPlatformUI/Presets/RandomPercentPreset.cs +++ b/CrossPlatformUI/Presets/RandomPercentPreset.cs @@ -95,6 +95,7 @@ public static class RandomPercentPreset GeneratorsAlwaysMatch = false, ShuffleEnemyHP = true, + ShuffleBossHP = EnemyLifeOption.MEDIUM, ShuffleXPStealers = true, ShuffleXPStolenAmount = true, ShuffleSwordImmunity = true, diff --git a/CrossPlatformUI/Presets/StandardPreset.cs b/CrossPlatformUI/Presets/StandardPreset.cs index 593c7338..08a44554 100644 --- a/CrossPlatformUI/Presets/StandardPreset.cs +++ b/CrossPlatformUI/Presets/StandardPreset.cs @@ -95,6 +95,7 @@ public static class StandardPreset GeneratorsAlwaysMatch = true, ShuffleEnemyHP = true, + ShuffleBossHP = EnemyLifeOption.MEDIUM, ShuffleXPStealers = true, ShuffleXPStolenAmount = true, ShuffleSwordImmunity = true, diff --git a/CrossPlatformUI/Presets/StandardSwissPreset.cs b/CrossPlatformUI/Presets/StandardSwissPreset.cs index f3db3815..b9318a5b 100644 --- a/CrossPlatformUI/Presets/StandardSwissPreset.cs +++ b/CrossPlatformUI/Presets/StandardSwissPreset.cs @@ -95,6 +95,7 @@ public static class StandardSwissPreset GeneratorsAlwaysMatch = true, ShuffleEnemyHP = true, + ShuffleBossHP = EnemyLifeOption.MEDIUM, ShuffleXPStealers = true, ShuffleXPStolenAmount = true, ShuffleSwordImmunity = true, diff --git a/CrossPlatformUI/Presets/UpstartsTournamentPreset.cs b/CrossPlatformUI/Presets/UpstartsTournamentPreset.cs index 48ce8b8d..676da277 100644 --- a/CrossPlatformUI/Presets/UpstartsTournamentPreset.cs +++ b/CrossPlatformUI/Presets/UpstartsTournamentPreset.cs @@ -89,6 +89,7 @@ public static class UpstartsTournamentPreset GeneratorsAlwaysMatch = true, ShuffleEnemyHP = true, + ShuffleBossHP = EnemyLifeOption.MEDIUM, ShuffleXPStealers = true, ShuffleXPStolenAmount = true, ShuffleSwordImmunity = true, diff --git a/CrossPlatformUI/ViewModels/GenerateRomViewModel.cs b/CrossPlatformUI/ViewModels/GenerateRomViewModel.cs index 2838cc72..d4c72aa5 100644 --- a/CrossPlatformUI/ViewModels/GenerateRomViewModel.cs +++ b/CrossPlatformUI/ViewModels/GenerateRomViewModel.cs @@ -46,9 +46,10 @@ public GenerateRomViewModel(MainViewModel main) var config = host.Config; var version = Assembly.GetEntryAssembly()!.GetName().Version!; var versionstr = $"{version.Major}.{version.Minor}.{version.Build}"; + var flags = config.SerializeFlags(); await clipboard.SetTextAsync($""" Version: {versionstr} -Flags: {config.Flags} +Flags: {flags} Seed: {config.Seed} ``` {lastError} @@ -94,13 +95,14 @@ async void GenerateSeed() var output = await Task.Run(async () => await randomizer.Randomize(romdata, config, UpdateProgress, tokenSource.Token)); if(!tokenSource.IsCancellationRequested) { - var filename = $"Z2_{config.Seed}_{config.Flags}.nes"; + var flags = config.SerializeFlags(); + var filename = $"Z2_{config.Seed}_{flags}.nes"; await files.SaveGeneratedBinaryFile(filename, output!, Main.OutputFilePath); if (config.GenerateSpoiler) { - var spoilerFilename = $"Z2_{config.Seed}_{config.Flags}_spoiler.txt"; + var spoilerFilename = $"Z2_{config.Seed}_{flags}_spoiler.txt"; await files.SaveSpoilerFile(spoilerFilename, randomizer.GenerateSpoiler(), Main.OutputFilePath); - var spoilerMapFilename = $"Z2_{config.Seed}_{config.Flags}_spoiler.png"; + var spoilerMapFilename = $"Z2_{config.Seed}_{flags}_spoiler.png"; await files.SaveGeneratedBinaryFile(spoilerMapFilename, new Spoiler(randomizer.ROMData).CreateSpoilerImage(randomizer.worlds), Main.OutputFilePath); } ProgressHeading = "Generation Complete"; diff --git a/CrossPlatformUI/ViewModels/MainViewModel.cs b/CrossPlatformUI/ViewModels/MainViewModel.cs index 1736e025..5d612bdf 100644 --- a/CrossPlatformUI/ViewModels/MainViewModel.cs +++ b/CrossPlatformUI/ViewModels/MainViewModel.cs @@ -1,9 +1,10 @@ using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Reactive; using System.Reactive.Disposables; +using System.Reactive.Linq; using System.Text.Json.Serialization; -using System.Diagnostics.CodeAnalysis; -using System.Reactive.Disposables.Fluent; using ReactiveUI; using ReactiveUI.Validation.Helpers; using Z2Randomizer.RandomizerCore; @@ -15,6 +16,11 @@ public class MainViewModel : ReactiveValidationObject, IScreen, IActivatableView { public string? OutputFilePath { get; set; } private RandomizerConfiguration config = new(); + /// Useful inexpensive shared observable for views to attach onto + /// for chaining change detection logic + public IObservable FlagsChanged { get; } + + public IObservable FlagsObservable { get; } [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] public RandomizerConfiguration Config { get => config; set => this.RaiseAndSetIfChanged(ref config, value); } @@ -34,6 +40,20 @@ public class MainViewModel : ReactiveValidationObject, IScreen, IActivatableView public MainViewModel() { + FlagsChanged = Observable.FromEventPattern( + h => Config.PropertyChanged += h, + h => Config.PropertyChanged -= h) + .Where(e => e.EventArgs.PropertyName == "Flags") + .Select(_ => Unit.Default) + .Replay(1) + .RefCount(); + + FlagsObservable = FlagsChanged + .Select(_ => this.Config.SerializeFlags()) + .DistinctUntilChanged() + .Replay(1) + .RefCount(); + RomFileViewModel = new(this); GenerateRomViewModel = new(this); SaveNewPresetViewModel = new(this); @@ -43,20 +63,16 @@ public MainViewModel() GenerateRom = ReactiveCommand.CreateFromObservable( () => Router.Navigate.Execute() ); - - this.WhenActivated(ShowRomFileViewIfNoRom); - return; - void ShowRomFileViewIfNoRom(CompositeDisposable disposables) + + this.WhenActivated((CompositeDisposable disposables) => { if (!RomFileViewModel.HasRomData) { Router.Navigate.Execute(RomFileViewModel); } - Disposable.Create(() => { }) - .DisposeWith(disposables); - } + }); } - + // Window/Desktop specific data private const int DefaultWidth = 900; diff --git a/CrossPlatformUI/ViewModels/RandomizerViewModel.cs b/CrossPlatformUI/ViewModels/RandomizerViewModel.cs index 5b6bd281..b95b342f 100644 --- a/CrossPlatformUI/ViewModels/RandomizerViewModel.cs +++ b/CrossPlatformUI/ViewModels/RandomizerViewModel.cs @@ -3,7 +3,9 @@ using System.Linq; using System.Reactive; using System.Reactive.Disposables; +using System.Reactive.Disposables.Fluent; using System.Reactive.Linq; +using System.Reactive.Subjects; using System.Text.Json.Serialization; using Microsoft.Extensions.DependencyInjection; using Avalonia.Controls; @@ -11,16 +13,22 @@ using ReactiveUI; using ReactiveUI.Validation.Extensions; using ReactiveUI.Validation.Helpers; +using Z2Randomizer.RandomizerCore; using CrossPlatformUI.Presets; using CrossPlatformUI.Services; using CrossPlatformUI.ViewModels.Tabs; -using Z2Randomizer.RandomizerCore; namespace CrossPlatformUI.ViewModels; [RequiresUnreferencedCode("ReactiveUI uses reflection")] public class RandomizerViewModel : ReactiveValidationObject, IRoutableViewModel, IActivatableViewModel { + [JsonIgnore] + public IObservable CanGenerateObservable { get; private set; } + + [JsonIgnore] + public BehaviorSubject FlagsValidSubject = new(true); + private bool IsFlagStringValid(string flags) { try @@ -34,6 +42,9 @@ private bool IsFlagStringValid(string flags) } } + [JsonIgnore] + public string FlagInput { get; set { field = value.Trim(); this.RaisePropertyChanged(); } } = ""; + [JsonIgnore] public string Seed { @@ -92,7 +103,7 @@ public RandomizerViewModel(MainViewModel main) { // By writing the flags like this, it will update all the reactive elements watching each // individual fields. - Main.Config.Flags = config.Flags; + Main.Config.DeserializeFlags(config.SerializeFlags()); }); LoadRom = ReactiveCommand.CreateFromObservable( @@ -129,18 +140,16 @@ public RandomizerViewModel(MainViewModel main) ThemeVariantName = App.Current.RequestedThemeVariant?.Key.ToString() ?? "Default"; }); - CanGenerate = this.WhenAnyValue( - x => x.Flags, - x => x.Main.Config.Seed, - x => x.Main.RomFileViewModel.HasRomData, - (flags, seed, hasRomData) => - IsFlagStringValid(flags) && !string.IsNullOrWhiteSpace(seed) && hasRomData - ).CombineLatest(this.Main.GenerateRomViewModel.IsRunning, - (validInput, alreadyRunning) => validInput && !alreadyRunning); + var seedValidObservable = this.WhenAnyValue(x => x.Main.Config.Seed, seed => !string.IsNullOrWhiteSpace(seed)); + + CanGenerateObservable = Observable.CombineLatest( + FlagsValidSubject, seedValidObservable, Main.RomFileViewModel.HasRomDataObservable, Main.GenerateRomViewModel.IsRunning, + (flagsValid, seedValid, hasRom, isRunning) => flagsValid && seedValid && hasRom && !isRunning); + Generate = ReactiveCommand.Create(() => { Main.GenerateRomDialogOpen = true; - }, CanGenerate); + }, CanGenerateObservable); VisitDiscord = ReactiveCommand.CreateFromTask(async control => { @@ -166,14 +175,7 @@ public RandomizerViewModel(MainViewModel main) }); SaveAsPreset = ReactiveCommand.Create((string name) => { - var updatedPreset = new CustomPreset - { - Preset = name, - Config = - { - Flags = Main.Config.Flags - } - }; + var updatedPreset = new CustomPreset(name, new RandomizerConfiguration(Main.Config.SerializeFlags())); var collection = Main.SaveNewPresetViewModel.SavedPresets; // makeshift FindIndex since ObservableCollection doesn't have one int presetIndex = -1; @@ -197,42 +199,40 @@ public RandomizerViewModel(MainViewModel main) this.WhenActivated(OnActivate); } - private void OnActivate(CompositeDisposable disposable) + private void OnActivate(CompositeDisposable disposables) { - // If the Flags are entirely default, use the beginner preset - Flags = Main.Config.Flags == new RandomizerConfiguration().Flags - ? BeginnerPreset.Preset.Flags - : Main.Config.Flags.Trim() ?? ""; + var loadedFlags = Main.Config.SerializeFlags(); // this serializes the configuration + var defaultFlags = new RandomizerConfiguration().SerializeFlags(); + // If the flags are entirely default, use the beginner preset + if (loadedFlags == defaultFlags) + { + Main.Config.DeserializeFlags(BeginnerPreset.Preset.SerializeFlags()); + } + + // flag updates from RandomizerConfiguration always overwrites our flag input + Main.FlagsObservable + .Subscribe(flags => FlagInput = flags) + .DisposeWith(disposables); + + this.WhenAnyValue(x => x.FlagInput) + .WithLatestFrom(Main.FlagsObservable, + (Input, Current) => (Input, Current, IsValid: Input == Current || IsFlagStringValid(Input))) + .Do(x => FlagsValidSubject.OnNext(x.IsValid)) + .Where(x => x.IsValid && x.Input != x.Current) + .Subscribe(x => Main.Config.DeserializeFlags(x.Input)) + .DisposeWith(disposables); Main.Config.PropertyChanged += (sender, args) => { switch (args.PropertyName) { - case "Flags": - Flags = ((RandomizerConfiguration)sender!).Flags; - break; case "Seed": this.RaisePropertyChanged(nameof(Seed)); break; } }; - Main.RomFileViewModel.PropertyChanged += (_, args) => - { - switch (args.PropertyName) - { - case "HasRomData": - this.RaisePropertyChanged(nameof(CanGenerate)); - break; - } - }; - var flagsValidation = this.WhenAnyValue( - x => x.Flags, - IsFlagStringValid - ); - this.ValidationRule( - x => x.Flags, - flagsValidation, - "Invalid Flags"); + + this.ValidationRule(x => x.FlagInput, FlagsValidSubject, "Invalid Flags"); AddValidationRules(); } @@ -306,23 +306,6 @@ private void AddValidationRules() }); } - private string validatedFlags = ""; - - [JsonIgnore] - public string Flags - { - get => validatedFlags; - set - { - string trimmedValue = value.Trim() ?? ""; - if (IsFlagStringValid(trimmedValue) && value != Main.Config.Flags) - { - Main.Config.Flags = trimmedValue; - } - this.RaiseAndSetIfChanged(ref validatedFlags, trimmedValue); - } - } - [JsonIgnore] public bool IsDesktop { get; } = !OperatingSystem.IsBrowser(); @@ -354,8 +337,6 @@ public string Flags public ReactiveCommand LoadPreset { get; } [JsonIgnore] public ReactiveCommand LoadRom { get; } - [JsonIgnore] - public IObservable CanGenerate { get; private set; } // Unique identifier for the routable view model. [JsonIgnore] diff --git a/CrossPlatformUI/ViewModels/RomFileViewModel.cs b/CrossPlatformUI/ViewModels/RomFileViewModel.cs index d9ce9b34..43811cf7 100644 --- a/CrossPlatformUI/ViewModels/RomFileViewModel.cs +++ b/CrossPlatformUI/ViewModels/RomFileViewModel.cs @@ -31,6 +31,9 @@ public byte[] RomData [JsonIgnore] public bool HasRomData => !RomData.IsNullOrEmpty(); + [JsonIgnore] + public IObservable HasRomDataObservable => this.WhenAnyValue(x => x.HasRomData); + [JsonConstructor] #pragma warning disable CS8618 public RomFileViewModel() {} diff --git a/CrossPlatformUI/ViewModels/SaveNewPresetViewModel.cs b/CrossPlatformUI/ViewModels/SaveNewPresetViewModel.cs index dd0183af..57235a97 100644 --- a/CrossPlatformUI/ViewModels/SaveNewPresetViewModel.cs +++ b/CrossPlatformUI/ViewModels/SaveNewPresetViewModel.cs @@ -4,6 +4,7 @@ using System.Reactive; using System.Reactive.Disposables; using System.Reactive.Linq; +using System.Text.Json; using System.Text.Json.Serialization; using DynamicData.Binding; using ReactiveUI; @@ -12,14 +13,57 @@ namespace CrossPlatformUI.ViewModels; - - public class CustomPreset : ReactiveObject { + /// name of preset private string preset = ""; public string Preset { get => preset; set => this.RaiseAndSetIfChanged(ref preset, value); } - private RandomizerConfiguration config = new (); - public RandomizerConfiguration Config { get => config; set => this.RaiseAndSetIfChanged(ref config, value); } + + private RandomizerConfiguration? config; + + // presets might be created in different versions with options that + // are not known to this version. keep the original raw preset JSON + // to avoid changing presets unexpectedly. + [JsonPropertyName("Config")] + public JsonElement? RawConfig { get; set; } + [JsonIgnore] + public RandomizerConfiguration Config + { + get + { + if (config == null) + { + if (RawConfig.HasValue) + { + // Deserialize with safe options to handle modified Enums affecting presets + var parsed = JsonSerializer.Deserialize(RawConfig.Value, SerializationContext.CreateSafeOptions()); + if (parsed != null) + { + config = parsed; + return config; + } + } + config = new RandomizerConfiguration(); + } + return config; + } + set + { + RawConfig = JsonSerializer.SerializeToElement(value, SerializationContext.Default.RandomizerConfiguration); + this.RaiseAndSetIfChanged(ref config, value); + } + } + + /// empty constructor for serialization only + public CustomPreset() + { + } + + public CustomPreset(string preset, RandomizerConfiguration config) + { + Preset = preset; + Config = config; + } } [RequiresUnreferencedCode("ReactiveUI uses reflection")] @@ -40,14 +84,7 @@ public SaveNewPresetViewModel(MainViewModel main) SavePreset = ReactiveCommand.Create(() => { Main.SaveNewPresetDialogOpen = false; // Setting the preset config through the flags creates a deep clone instead of a reference - var preset = new CustomPreset - { - Preset = PresetName, - Config = - { - Flags = Main.Config.Flags - } - }; + var preset = new CustomPreset(PresetName, new RandomizerConfiguration(Main.Config.SerializeFlags())); SavedPresets.Add(preset); }); CancelPreset = ReactiveCommand.Create(() => @@ -91,4 +128,4 @@ public SaveNewPresetViewModel(MainViewModel main) public IScreen HostScreen { get; } [JsonIgnore] public ViewModelActivator Activator { get; } -} \ No newline at end of file +} diff --git a/CrossPlatformUI/Views/RandomizerView.axaml b/CrossPlatformUI/Views/RandomizerView.axaml index c26ddbcc..c51c02cc 100644 --- a/CrossPlatformUI/Views/RandomizerView.axaml +++ b/CrossPlatformUI/Views/RandomizerView.axaml @@ -19,7 +19,7 @@ - +