diff --git a/.gitmodules b/.gitmodules index 1701ce09..c4c4230f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ -[submodule "MotelyJAML"] - path = src/MotelyJAML - url = https://github.com/OptimusPi/MotelyJAML.git [submodule "src/MotelyJAML"] path = src/MotelyJAML url = https://github.com/OptimusPi/MotelyJAML.git diff --git a/BalatroSeedOracle.sln b/BalatroSeedOracle.sln index 777d020d..c23ba12f 100644 --- a/BalatroSeedOracle.sln +++ b/BalatroSeedOracle.sln @@ -4,8 +4,6 @@ VisualStudioVersion = 17.5.2.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "external", "external", "{A288BEE0-695A-BEEA-455C-649571D89326}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BalatroSeedOracle", "src\BalatroSeedOracle\BalatroSeedOracle.csproj", "{8A9CDFC3-0A84-2161-EF0D-78B2610389F8}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BalatroSeedOracle.Android", "src\BalatroSeedOracle.Android\BalatroSeedOracle.Android.csproj", "{C7E811EE-10D0-2CD9-143B-0B5BB78B26CA}" @@ -20,12 +18,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "MotelyJAML", "MotelyJAML", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Motely", "src\MotelyJAML\Motely\Motely.csproj", "{EDC7B3DB-A702-4B21-1617-CF0073CD45B3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Motely.DB", "src\MotelyJAML\Motely.DB\Motely.DB.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Motely.Orchestration", "src\MotelyJAML\Motely.Orchestration\Motely.Orchestration.csproj", "{C3D4E5F6-A7B8-9012-CDEF-123456789012}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Motely.API", "src\MotelyJAML\Motely.API\Motely.API.csproj", "{E8AFF8C0-8A08-EF5F-1857-CC83539C61E1}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Motely.Tests", "src\MotelyJAML\Motely.Tests\Motely.Tests.csproj", "{49688DA2-4307-246E-963D-4C09A7C3DAB7}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Motely.TUI", "src\MotelyJAML\Motely.TUI\Motely.TUI.csproj", "{287004BE-1C2A-7335-791E-EC405C0D9760}" @@ -60,18 +52,6 @@ Global {EDC7B3DB-A702-4B21-1617-CF0073CD45B3}.Debug|Any CPU.Build.0 = Debug|Any CPU {EDC7B3DB-A702-4B21-1617-CF0073CD45B3}.Release|Any CPU.ActiveCfg = Release|Any CPU {EDC7B3DB-A702-4B21-1617-CF0073CD45B3}.Release|Any CPU.Build.0 = Release|Any CPU - {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU - {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.Build.0 = Release|Any CPU - {E8AFF8C0-8A08-EF5F-1857-CC83539C61E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E8AFF8C0-8A08-EF5F-1857-CC83539C61E1}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E8AFF8C0-8A08-EF5F-1857-CC83539C61E1}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E8AFF8C0-8A08-EF5F-1857-CC83539C61E1}.Release|Any CPU.Build.0 = Release|Any CPU {49688DA2-4307-246E-963D-4C09A7C3DAB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {49688DA2-4307-246E-963D-4C09A7C3DAB7}.Debug|Any CPU.Build.0 = Debug|Any CPU {49688DA2-4307-246E-963D-4C09A7C3DAB7}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -90,11 +70,7 @@ Global {63DC9E55-7F48-0184-AA81-683B823F1301} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {8C89F91A-2F03-FBDE-11B5-95045AD46440} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {F59D3181-6EFA-D9CA-90D5-1085C46EEAAF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} - {2C7DF5F9-C613-49D4-3348-9C271BBB65CD} = {A288BEE0-695A-BEEA-455C-649571D89326} {EDC7B3DB-A702-4B21-1617-CF0073CD45B3} = {2C7DF5F9-C613-49D4-3348-9C271BBB65CD} - {B2C3D4E5-F6A7-8901-BCDE-F12345678901} = {2C7DF5F9-C613-49D4-3348-9C271BBB65CD} - {C3D4E5F6-A7B8-9012-CDEF-123456789012} = {2C7DF5F9-C613-49D4-3348-9C271BBB65CD} - {E8AFF8C0-8A08-EF5F-1857-CC83539C61E1} = {2C7DF5F9-C613-49D4-3348-9C271BBB65CD} {49688DA2-4307-246E-963D-4C09A7C3DAB7} = {2C7DF5F9-C613-49D4-3348-9C271BBB65CD} {287004BE-1C2A-7335-791E-EC405C0D9760} = {2C7DF5F9-C613-49D4-3348-9C271BBB65CD} EndGlobalSection diff --git a/src/BalatroSeedOracle.Desktop/BalatroSeedOracle.Desktop.csproj b/src/BalatroSeedOracle.Desktop/BalatroSeedOracle.Desktop.csproj index ee169cad..73247495 100644 --- a/src/BalatroSeedOracle.Desktop/BalatroSeedOracle.Desktop.csproj +++ b/src/BalatroSeedOracle.Desktop/BalatroSeedOracle.Desktop.csproj @@ -55,6 +55,5 @@ - diff --git a/src/BalatroSeedOracle.Desktop/Components/Widgets/ApiHostWidget.axaml b/src/BalatroSeedOracle.Desktop/Components/Widgets/ApiHostWidget.axaml deleted file mode 100644 index 625899cf..00000000 --- a/src/BalatroSeedOracle.Desktop/Components/Widgets/ApiHostWidget.axaml +++ /dev/null @@ -1,266 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/BalatroSeedOracle.Desktop/Components/Widgets/ApiHostWidget.axaml.cs b/src/BalatroSeedOracle.Desktop/Components/Widgets/ApiHostWidget.axaml.cs deleted file mode 100644 index b54a1ef3..00000000 --- a/src/BalatroSeedOracle.Desktop/Components/Widgets/ApiHostWidget.axaml.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Avalonia.Markup.Xaml; -using BalatroSeedOracle.Components; -using BalatroSeedOracle.ViewModels; - -namespace BalatroSeedOracle.Desktop.Components.Widgets; - -/// -/// ApiHostWidget - Hosts the Motely API server within BSO -/// DataContext is bound via XAML from parent ViewModel (the Avalonia way) -/// -public partial class ApiHostWidget : BaseWidgetControl -{ - public ApiHostWidget() - { - InitializeComponent(); - } - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } - - // Cleanup when widget is unloaded - protected override void OnUnloaded(Avalonia.Interactivity.RoutedEventArgs e) - { - base.OnUnloaded(e); - if (DataContext is ApiHostWidgetViewModel vm) - { - _ = vm.CleanupAsync(); - } - } -} diff --git a/src/BalatroSeedOracle.Desktop/DesktopAppExtensions.cs b/src/BalatroSeedOracle.Desktop/DesktopAppExtensions.cs index 1f5e4d95..f787fc5f 100644 --- a/src/BalatroSeedOracle.Desktop/DesktopAppExtensions.cs +++ b/src/BalatroSeedOracle.Desktop/DesktopAppExtensions.cs @@ -4,7 +4,6 @@ using BalatroSeedOracle.Desktop.Views; using BalatroSeedOracle.Helpers; using BalatroSeedOracle.Services; -using Motely.DB; using Motely.Executors; namespace BalatroSeedOracle.Desktop; @@ -69,8 +68,6 @@ private static async Task InitializeSearchLibraryAsync() { try { - MotelySearchOrchestrator.SetRepository(new MotelyRepository()); - // Set thread budget for MultiSearchManager MultiSearchManager.Instance.SetTotalThreads(Environment.ProcessorCount); DebugLogger.Log("App", $"Thread budget set to: {Environment.ProcessorCount}"); diff --git a/src/BalatroSeedOracle.Desktop/Program.cs b/src/BalatroSeedOracle.Desktop/Program.cs index 49b01145..ce1ac7a5 100644 --- a/src/BalatroSeedOracle.Desktop/Program.cs +++ b/src/BalatroSeedOracle.Desktop/Program.cs @@ -42,17 +42,9 @@ public static void Main(string[] args) // Desktop-only services services.AddSingleton(); services.AddSingleton(); - services.AddSingleton< - ISequentialLibraryInitializer, - SequentialLibraryInitializerService - >(); - services.AddSingleton< - IRestoreActiveSearchesProvider, - RestoreActiveSearchesProviderService - >(); - - // API host - services.AddSingleton(); + // Sequential library, restore-active-searches, and in-app API host all + // lived behind Motely.DB / Motely.API which are gone. Re-register when + // their replacements arrive on top of JamlSearchBuilder. }; // Start Avalonia diff --git a/src/BalatroSeedOracle.Desktop/Services/DesktopApiHostService.cs b/src/BalatroSeedOracle.Desktop/Services/DesktopApiHostService.cs deleted file mode 100644 index 9239fe85..00000000 --- a/src/BalatroSeedOracle.Desktop/Services/DesktopApiHostService.cs +++ /dev/null @@ -1,136 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using BalatroSeedOracle.Services; -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.Hosting; -using Motely.API; -using Motely.Executors; - -namespace BalatroSeedOracle.Desktop.Services; - -/// -/// Desktop implementation of IApiHostService using Motely.API -/// -public class DesktopApiHostService : IApiHostService -{ - private WebApplication? _server; - private CancellationTokenSource? _cts; - - public bool IsSupported => true; - public bool IsRunning { get; private set; } - public string ServerUrl { get; private set; } = "http://localhost:3141/"; - - public event Action? LogMessage; - public event Action? StatusChanged; - - public async Task StartAsync(int port) - { - if (IsRunning) - return; - - try - { - ServerUrl = $"http://localhost:{port}/"; - var args = new[] { "--urls", ServerUrl }; - - Log($"Starting Motely API on {ServerUrl}"); - - _cts = new CancellationTokenSource(); - _server = MotelyApiHost.CreateHost(args); - - // Set thread budget for MultiSearchManager - var threadCount = Environment.ProcessorCount; - MultiSearchManager.Instance.SetTotalThreads(threadCount); - Log($"Thread budget: {threadCount}"); - - IsRunning = true; - StatusChanged?.Invoke(true); - - Log("Server started successfully"); - Log($"Web UI: {ServerUrl}"); - Log($"Health: {ServerUrl}health"); - Log($"SignalR Hub: {ServerUrl}searchHub"); - - // Run server in background - _ = Task.Run(async () => - { - try - { - await _server.RunAsync(_cts.Token); - } - catch (OperationCanceledException) - { - // Expected during stop - } - catch (Exception ex) - { - Log($"Server error: {ex.Message}"); - await StopInternalAsync(); - } - }); - } - catch (Exception ex) - { - Log($"Failed to start: {ex.Message}"); - IsRunning = false; - StatusChanged?.Invoke(false); - throw; - } - } - - public async Task StopAsync() - { - if (!IsRunning) - return; - - Log("Stopping server..."); - - // Stop all searches first - try - { - MultiSearchManager.Instance.StopAll(); - Log("Stopped all active searches"); - } - catch (Exception ex) - { - Log($"Warning stopping searches: {ex.Message}"); - } - - await StopInternalAsync(); - Log("Server stopped"); - } - - private async Task StopInternalAsync() - { - _cts?.Cancel(); - - if (_server is not null) - { - try - { - using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - await _server.StopAsync(stopCts.Token); - } - catch { } - - try - { - await _server.DisposeAsync(); - } - catch { } - } - - _server = null; - _cts?.Dispose(); - _cts = null; - - IsRunning = false; - StatusChanged?.Invoke(false); - } - - private void Log(string message) - { - LogMessage?.Invoke(message); - } -} diff --git a/src/BalatroSeedOracle.Desktop/Services/RestoreActiveSearchesProviderService.cs b/src/BalatroSeedOracle.Desktop/Services/RestoreActiveSearchesProviderService.cs deleted file mode 100644 index ddb7ef8f..00000000 --- a/src/BalatroSeedOracle.Desktop/Services/RestoreActiveSearchesProviderService.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading.Tasks; -using BalatroSeedOracle.Services; -using Motely; -using Motely.DB; -using Motely.Filters; - -namespace BalatroSeedOracle.Desktop.Services; - -/// -/// Desktop implementation: uses Motely.DB.SequentialLibrary for metadata, loads JAML in BSO. -/// -public sealed class RestoreActiveSearchesProviderService : IRestoreActiveSearchesProvider -{ - /// - public Task> RestoreAsync(string jamlFiltersDir) - { - var restored = new List(); - - try - { - var activeIds = SequentialLibrary.Instance.GetAllActiveSearchIds(); - - foreach (var searchId in activeIds) - { - try - { - var meta = SequentialLibrary.Instance.GetSearchMeta(searchId); - if (meta is null) - continue; - - var jamlPath = Path.Combine(jamlFiltersDir, $"{meta.JamlFilter}.jaml"); - if (!File.Exists(jamlPath)) - { - SequentialLibrary.Instance.SetSearchActive(searchId, false); - continue; - } - - if ( - !JamlConfigLoader.TryLoadFromJaml(jamlPath, out var config, out _) - || config is null - ) - { - SequentialLibrary.Instance.SetSearchActive(searchId, false); - continue; - } - - config.Deck = meta.Deck; - config.Stake = meta.Stake; - - restored.Add( - new RestoredSearchInfo - { - SearchId = searchId, - FilterName = meta.JamlFilter ?? "Unknown", - Deck = meta.Deck ?? "Red", - Stake = meta.Stake ?? "White", - LastSeed = meta.LastSeed, - TotalSeedsProcessed = meta.TotalSeedsProcessed, - TotalMatches = meta.TotalMatches, - Config = config, - } - ); - } - catch - { - // Skip broken entries - } - } - } - catch - { - // Return whatever we have - } - - return Task.FromResult(restored); - } -} diff --git a/src/BalatroSeedOracle.Desktop/Services/ResultsDatabaseExporter.cs b/src/BalatroSeedOracle.Desktop/Services/ResultsDatabaseExporter.cs deleted file mode 100644 index 73c8325d..00000000 --- a/src/BalatroSeedOracle.Desktop/Services/ResultsDatabaseExporter.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using BalatroSeedOracle.Models; -using BalatroSeedOracle.Services.Export; -using Motely.DB; - -namespace BalatroSeedOracle.Desktop.Services; - -/// -/// Desktop implementation of IResultsDatabaseExporter using Motely.DB. -/// BSO does not reference DuckDB directly; all database export goes through Motely. -/// -public sealed class ResultsDatabaseExporter : IResultsDatabaseExporter -{ - public bool IsAvailable => true; - - public Task ExportToAsync( - string path, - IReadOnlyList results, - IReadOnlyList columnNames - ) - { - var rows = new List<(string seed, int score, IReadOnlyList? columnValues)>(); - foreach (var r in results) - { - IReadOnlyList? vals = null; - if (r.Scores != null && r.Scores.Length > 0) - vals = r.Scores.Cast().ToArray(); - rows.Add((r.Seed ?? "", r.TotalScore, vals)); - } - ResultsExportHelper.ExportResultsTo(path, columnNames ?? new List(), rows); - return Task.CompletedTask; - } -} diff --git a/src/BalatroSeedOracle.Desktop/Services/SequentialLibraryInitializerService.cs b/src/BalatroSeedOracle.Desktop/Services/SequentialLibraryInitializerService.cs deleted file mode 100644 index 6186e5e4..00000000 --- a/src/BalatroSeedOracle.Desktop/Services/SequentialLibraryInitializerService.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; -using BalatroSeedOracle.Services; -using Motely.DB; - -namespace BalatroSeedOracle.Desktop.Services; - -/// -/// Desktop implementation: delegates to Motely.DB.SequentialLibrary. -/// -public sealed class SequentialLibraryInitializerService : ISequentialLibraryInitializer -{ - /// - public void SetLibraryRoot(string path) - { - SequentialLibrary.SetLibraryRoot(path); - } -} diff --git a/src/BalatroSeedOracle.Desktop/Views/MainWindowExtensions.cs b/src/BalatroSeedOracle.Desktop/Views/MainWindowExtensions.cs index bc236d02..93b83149 100644 --- a/src/BalatroSeedOracle.Desktop/Views/MainWindowExtensions.cs +++ b/src/BalatroSeedOracle.Desktop/Views/MainWindowExtensions.cs @@ -78,25 +78,8 @@ private static void AddDesktopWidgets(BalatroMainMenu mainMenu) return; } - // Add API Host Widget - if (viewModel.ApiHostWidgetViewModel != null) - { - var apiHostWidget = new ApiHostWidget - { - DataContext = viewModel.ApiHostWidgetViewModel, - ClipToBounds = false, - [Avalonia.Visual.ZIndexProperty] = viewModel.ApiHostWidgetViewModel.WidgetZIndex, - }; - - BindVisibility( - apiHostWidget, - viewModel, - nameof(BalatroMainMenuViewModel.IsHostApiWidgetVisible), - () => viewModel.IsHostApiWidgetVisible - ); - desktopCanvas.Children.Add(apiHostWidget); - DebugLogger.Log("DesktopWidgetInit", "Added ApiHostWidget"); - } + // API Host Widget removed alongside Motely.API. The toggle in the menu is + // kept as a no-op for now so user preferences round-trip. // Add Music Mixer Widget var musicMixerVm = ServiceHelper.GetService(); diff --git a/src/BalatroSeedOracle/BalatroSeedOracle.csproj b/src/BalatroSeedOracle/BalatroSeedOracle.csproj index 39e45948..98ced631 100644 --- a/src/BalatroSeedOracle/BalatroSeedOracle.csproj +++ b/src/BalatroSeedOracle/BalatroSeedOracle.csproj @@ -70,7 +70,6 @@ - diff --git a/src/BalatroSeedOracle/Extensions/ServiceCollectionExtensions.cs b/src/BalatroSeedOracle/Extensions/ServiceCollectionExtensions.cs index a2f62a2f..c0afdfc9 100644 --- a/src/BalatroSeedOracle/Extensions/ServiceCollectionExtensions.cs +++ b/src/BalatroSeedOracle/Extensions/ServiceCollectionExtensions.cs @@ -68,7 +68,6 @@ this IServiceCollection services sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService>(), - sp.GetService(), sp.GetService(), sp.GetService(), sp.GetService(), diff --git a/src/BalatroSeedOracle/Helpers/ModalHelper.cs b/src/BalatroSeedOracle/Helpers/ModalHelper.cs index 1883c1f3..408b492f 100644 --- a/src/BalatroSeedOracle/Helpers/ModalHelper.cs +++ b/src/BalatroSeedOracle/Helpers/ModalHelper.cs @@ -209,8 +209,7 @@ Motely.Filters.MotelyJsonConfig config public static StandardModal ShowToolsModal(this Views.BalatroMainMenu menu) { var userProfile = ServiceHelper.GetRequiredService(); - var apiHost = ServiceHelper.GetService(); - var ToolView = new ToolsModal(userProfile, apiHost); + var ToolView = new ToolsModal(userProfile); return menu.ShowModal("MORE", ToolView); } diff --git a/src/BalatroSeedOracle/Motely/IMotelySearchContext.cs b/src/BalatroSeedOracle/Motely/IMotelySearchContext.cs new file mode 100644 index 00000000..068cf337 --- /dev/null +++ b/src/BalatroSeedOracle/Motely/IMotelySearchContext.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; + +namespace Motely.Executors; + +// BSO-owned re-creation of the IMotelySearchContext surface BSO already calls +// against. Upstream Motely no longer exposes a wrapping search context +// (`Motely.Orchestration` was dissolved); this is the seam BSO needs in order +// to keep the UI talking to a stable shape while the JamlConfig-driven engine +// is wired up underneath. +public enum MotelySearchStatus +{ + Created, + Running, + Paused, + Completed, + Cancelled, + Failed, +} + +public sealed class MotelySearchResultRow +{ + public string Seed { get; set; } = string.Empty; + public int Score { get; set; } + public int[]? Tallies { get; set; } +} + +public interface IMotelySearchContext : IDisposable +{ + string SearchId { get; } + string FilterId { get; } + MotelySearchStatus Status { get; } + TimeSpan ElapsedTime { get; } + long TotalSeedsSearched { get; } + long MatchingSeeds { get; } + long FilteredSeeds { get; } + int ResultCount { get; } + IReadOnlyList ColumnNames { get; } + IList GetResults(int offset, int count); + IList GetTopResults(int limit = 1000); + void ExportTo(string outputPath); + void Start(); + void Pause(); + void Cancel(); +} diff --git a/src/BalatroSeedOracle/Motely/JamlConfigBridge.cs b/src/BalatroSeedOracle/Motely/JamlConfigBridge.cs new file mode 100644 index 00000000..30083ce0 --- /dev/null +++ b/src/BalatroSeedOracle/Motely/JamlConfigBridge.cs @@ -0,0 +1,168 @@ +using System; +using System.Collections.Generic; +using System.Text; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace Motely.Filters; + +// Converts BSO's legacy MotelyJsonConfig (discriminator-keyed clauses) into the +// new MotelyJAML JAML format (typed-key clauses) by emitting YAML and feeding it +// to JamlConfigLoader.TryLoad. Lets BSO keep its UI / file format unchanged +// while still driving the new JamlSearchBuilder pipeline. +public static class JamlConfigBridge +{ + public static bool TryConvertToJaml( + MotelyJsonConfig config, + out JamlConfig? jaml, + out string? error, + out string yamlText) + { + yamlText = EmitYaml(config); + if (!JamlConfigLoader.TryLoad(yamlText, out jaml, out error)) + return false; + return jaml is not null; + } + + public static string EmitYaml(MotelyJsonConfig config) + { + var root = new Dictionary(); + if (!string.IsNullOrWhiteSpace(config.Name)) + root["name"] = config.Name; + if (!string.IsNullOrWhiteSpace(config.Description)) + root["description"] = config.Description; + if (!string.IsNullOrWhiteSpace(config.Author)) + root["author"] = config.Author; + if (!string.IsNullOrWhiteSpace(config.Deck)) + root["deck"] = config.Deck; + if (!string.IsNullOrWhiteSpace(config.Stake)) + root["stake"] = config.Stake; + + var must = EmitClauseList(config.Must); + if (must.Count > 0) root["must"] = must; + + var should = EmitClauseList(config.Should); + if (should.Count > 0) root["should"] = should; + + var mustNot = EmitClauseList(config.MustNot); + if (mustNot.Count > 0) root["mustNot"] = mustNot; + + var serializer = new SerializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull) + .Build(); + return serializer.Serialize(root); + } + + private static List EmitClauseList(List? src) + { + var list = new List(); + if (src is null) return list; + foreach (var c in src) + { + var dict = EmitClause(c); + if (dict is not null) + list.Add(dict); + } + return list; + } + + private static Dictionary? EmitClause(MotelyJsonConfig.MotelyJsonFilterClause c) + { + var dict = new Dictionary(); + var (typedKey, isCompound) = MapTypeKey(c.Type); + if (typedKey is null) + return null; // unknown clause type — skip, JAML loader would reject anyway + + if (isCompound) + { + var nested = new List(); + if (c.Clauses is not null) + { + foreach (var child in c.Clauses) + { + var childDict = EmitClause(child); + if (childDict is not null) nested.Add(childDict); + } + } + dict[typedKey] = nested; + } + else if (c.Values is { Length: > 0 } values) + { + // typed lists in JAML are pluralised — pick the plural form. + var pluralKey = PluralizeKey(typedKey); + dict[pluralKey] = new List(values); + } + else if (!string.IsNullOrWhiteSpace(c.Value)) + { + dict[typedKey] = c.Value; + } + else + { + // Wildcard "Any" — the JAML loader accepts the singular key with value "Any". + dict[typedKey] = "Any"; + } + + if (c.Antes is { Length: > 0 }) dict["antes"] = new List(c.Antes); + if (c.Score != 1) dict["score"] = c.Score; + if (c.Min is int min) dict["min"] = min; + if (!string.IsNullOrWhiteSpace(c.Label)) dict["label"] = c.Label; + if (!string.IsNullOrWhiteSpace(c.Edition)) dict["edition"] = c.Edition; + if (c.Stickers is { Length: > 0 } stickers) dict["stickers"] = new List(stickers); + if (!string.IsNullOrWhiteSpace(c.Seal)) dict["seal"] = c.Seal; + if (!string.IsNullOrWhiteSpace(c.Enhancement)) dict["enhancement"] = c.Enhancement; + if (!string.IsNullOrWhiteSpace(c.Rank)) dict["rank"] = c.Rank; + if (!string.IsNullOrWhiteSpace(c.Suit)) dict["suit"] = c.Suit; + if (c.Rolls is { Length: > 0 } rolls) dict["rolls"] = new List(rolls); + + // Old "shopSlots" / "packSlots" become "shopItems" / "boosterPacks" on JAML clauses. + var shop = c.ShopSlots ?? c.Sources?.ShopSlots; + if (shop is { Length: > 0 }) dict["shopItems"] = new List(shop); + var packs = c.PackSlots ?? c.Sources?.PackSlots; + if (packs is { Length: > 0 }) dict["boosterPacks"] = new List(packs); + + return dict; + } + + private static (string? key, bool isCompound) MapTypeKey(string type) + { + if (string.IsNullOrWhiteSpace(type)) return (null, false); + return type.ToLowerInvariant() switch + { + "joker" => ("joker", false), + "commonjoker" => ("commonJoker", false), + "uncommonjoker" => ("uncommonJoker", false), + "rarejoker" => ("rareJoker", false), + "legendaryjoker" or "souljoker" => ("legendaryJoker", false), + "voucher" => ("voucher", false), + "tarotcard" or "tarot" => ("tarotCard", false), + "spectralcard" or "spectral" => ("spectralCard", false), + "planetcard" or "planet" => ("planetCard", false), + "boss" => ("boss", false), + "tag" or "smallblindtag" => ("smallBlindTag", false), + "bigblindtag" => ("bigBlindTag", false), + "standardcard" or "playingcard" => ("standardCard", false), + "erraticrank" => ("erraticRank", false), + "erraticsuit" => ("erraticSuit", false), + "erraticcard" => ("erraticCard", false), + "startingdraw" => ("startingDraw", false), + "and" => ("and", true), + "or" => ("or", true), + _ => (null, false), + }; + } + + private static string PluralizeKey(string singular) => singular switch + { + "joker" => "jokers", + "commonJoker" => "commonJokers", + "uncommonJoker" => "uncommonJokers", + "rareJoker" => "rareJokers", + "legendaryJoker" => "legendaryJokers", + "voucher" => "vouchers", + "tarotCard" => "tarotCards", + "spectralCard" => "spectralCards", + "standardCard" => "standardCards", + _ => singular, // single-valued clause types stay singular + }; +} diff --git a/src/BalatroSeedOracle/Motely/JamlConfigLoaderCompat.cs b/src/BalatroSeedOracle/Motely/JamlConfigLoaderCompat.cs new file mode 100644 index 00000000..2591c67d --- /dev/null +++ b/src/BalatroSeedOracle/Motely/JamlConfigLoaderCompat.cs @@ -0,0 +1,17 @@ +using Motely.Filters; + +namespace Motely; + +// Compatibility shim. BSO call sites reference `Motely.JamlConfigLoader.TryLoadFromJamlString` +// (the older API surface that produced MotelyJsonConfig). Upstream Motely moved the +// real loader to `Motely.Filters.JamlConfigLoader` and switched its output to +// JamlConfig. Until the call sites move to JamlConfig directly, this shim lets the +// existing names resolve and still produce a MotelyJsonConfig. +public static class JamlConfigLoader +{ + public static bool TryLoadFromJamlString(string jaml, out MotelyJsonConfig? config, out string? error) => + MotelyJsonConfigYaml.TryLoad(jaml, out config, out error); + + public static bool TryLoadFromJaml(string jaml, out MotelyJsonConfig? config, out string? error) => + MotelyJsonConfigYaml.TryLoad(jaml, out config, out error); +} diff --git a/src/BalatroSeedOracle/Motely/JsonSearchParams.cs b/src/BalatroSeedOracle/Motely/JsonSearchParams.cs new file mode 100644 index 00000000..18344ba5 --- /dev/null +++ b/src/BalatroSeedOracle/Motely/JsonSearchParams.cs @@ -0,0 +1,45 @@ +using System; +using System.Threading; +using Motely.Filters; + +namespace Motely.Executors; + +// Lifted from upstream Motely (removed when Motely.Orchestration was dissolved). +// BSO still keeps it as the parameter object handed to the bridge that translates +// MotelyJsonConfig → JamlConfig and runs a JamlSearchBuilder-driven search. +public enum ScoreCutoffMode +{ + None = 0, + Manual = 1, + AutoBest = 2, + AutoSmart = 3, +} + +public sealed class JsonSearchParams +{ + public int Threads { get; set; } = 1; + public int BatchCharCount { get; set; } = 4; + public ulong StartBatch { get; set; } + public ulong EndBatch { get; set; } + public string? SpecificSeed { get; set; } + public int RandomSeeds { get; set; } + public bool PalindromeSeeds { get; set; } + public int Cutoff { get; set; } + public ScoreCutoffMode CutoffMode { get; set; } + public bool Quiet { get; set; } + public bool NoFancy { get; set; } + public string? OutputDbPath { get; set; } + public CancellationToken CancellationToken { get; set; } + public Action? ResultCallback { get; set; } + + // Convenience aliases used by BSO call sites. + public int BatchSize + { + get => BatchCharCount; + set => BatchCharCount = value; + } + + public bool EnableDebug { get; set; } + public string? Deck { get; set; } + public string? Stake { get; set; } +} diff --git a/src/BalatroSeedOracle/Motely/MotelyJsonConfig.cs b/src/BalatroSeedOracle/Motely/MotelyJsonConfig.cs new file mode 100644 index 00000000..c88e5d39 --- /dev/null +++ b/src/BalatroSeedOracle/Motely/MotelyJsonConfig.cs @@ -0,0 +1,283 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; +using YamlDotNet.Serialization; + +namespace Motely.Filters; + +// Lifted from upstream Motely (deleted at commit de506102 in MotelyJAML) and slimmed down +// to the DTO surface BalatroSeedOracle actually uses. The internal partitioning, +// PostProcess pipeline, MotelyEnumParser, and per-clause *Enum properties are gone — +// they belonged to the old in-process Motely search runtime, which is now driven from +// JamlConfig in MotelyJAML. BSO keeps these as a serialization-friendly UI/config DTO +// and converts to JamlConfig at the search boundary. + +public class SourcesConfig +{ + [JsonPropertyName("shopSlots")] + [YamlMember(Alias = "shopSlots")] + public int[]? ShopSlots { get; set; } + + [JsonPropertyName("packSlots")] + [YamlMember(Alias = "packSlots")] + public int[]? PackSlots { get; set; } + + [JsonPropertyName("tags")] + [YamlMember(Alias = "tags")] + public bool? Tags { get; set; } + + [JsonPropertyName("requireMega")] + [YamlMember(Alias = "requireMega")] + public bool? RequireMega { get; set; } + + [JsonPropertyName("minShopSlot")] + [YamlMember(Alias = "minShopSlot")] + public int? MinShopSlot { get; set; } + + [JsonPropertyName("maxShopSlot")] + [YamlMember(Alias = "maxShopSlot")] + public int? MaxShopSlot { get; set; } + + [JsonPropertyName("minPackSlot")] + [YamlMember(Alias = "minPackSlot")] + public int? MinPackSlot { get; set; } + + [JsonPropertyName("maxPackSlot")] + [YamlMember(Alias = "maxPackSlot")] + public int? MaxPackSlot { get; set; } +} + +public class MotelyFilterDefaults +{ + [JsonPropertyName("antes")] + [YamlMember(Alias = "antes")] + public int[]? Antes { get; set; } + + [JsonPropertyName("packSlots")] + [YamlMember(Alias = "packSlots")] + public int[]? PackSlots { get; set; } + + [JsonPropertyName("shopSlots")] + [YamlMember(Alias = "shopSlots")] + public int[]? ShopSlots { get; set; } + + [JsonPropertyName("score")] + [YamlMember(Alias = "score")] + public int? Score { get; set; } + + [JsonIgnore] + [YamlIgnore] + public static readonly int[] DEFAULT_ANTES = [1, 2, 3, 4, 5, 6, 7, 8]; + + public int[] GetEffectiveAntes() => Antes ?? DEFAULT_ANTES; +} + +public enum MotelyScoreAggregationMode +{ + Sum, + Max, +} + +public class MotelyJsonConfig +{ + [JsonPropertyName("name")] + [YamlMember(Alias = "name")] + public string? Name { get; set; } + + [JsonPropertyName("author")] + [YamlMember(Alias = "author")] + public string? Author { get; set; } + + [JsonPropertyName("description")] + [YamlMember(Alias = "description")] + public string? Description { get; set; } + + [JsonPropertyName("dateCreated")] + public DateTime? DateCreated { get; set; } + + [JsonPropertyName("verifiedSeed")] + [YamlMember(Alias = "verifiedSeed")] + public string? VerifiedSeed { get; set; } + + [JsonPropertyName("deck")] + [YamlMember(Alias = "deck")] + public string? Deck { get; set; } = "Red"; + + [JsonPropertyName("stake")] + [YamlMember(Alias = "stake")] + public string? Stake { get; set; } = "White"; + + [JsonPropertyName("mode")] + [YamlMember(Alias = "mode")] + public string? Mode { get; set; } + + [JsonPropertyName("startSeed")] + [YamlMember(Alias = "startSeed")] + public string? StartSeed { get; set; } + + [JsonPropertyName("defaults")] + [YamlMember(Alias = "defaults")] + public MotelyFilterDefaults? Defaults { get; set; } + + [JsonPropertyName("must")] + [YamlMember(Alias = "must")] + public List Must { get; set; } = new(); + + [JsonPropertyName("should")] + [YamlMember(Alias = "should")] + public List Should { get; set; } = new(); + + [JsonPropertyName("mustNot")] + [YamlMember(Alias = "mustNot")] + public List MustNot { get; set; } = new(); + + public class MotelyJsonFilterClause + { + [JsonPropertyName("type")] + [YamlMember(Alias = "type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("value")] + [YamlMember(Alias = "value")] + public string? Value { get; set; } + + [JsonPropertyName("values")] + [YamlMember(Alias = "values")] + public string[]? Values { get; set; } + + [JsonPropertyName("label")] + [YamlMember(Alias = "label")] + public string? Label { get; set; } + + [JsonPropertyName("antes")] + [YamlMember(Alias = "antes")] + public int[]? Antes { get; set; } + + [JsonPropertyName("clauses")] + [YamlMember(Alias = "clauses")] + public List? Clauses { get; set; } + + [JsonIgnore] + [YamlIgnore] + public bool IsInverted { get; set; } + + [JsonPropertyName("score")] + [YamlMember(Alias = "score")] + public int Score { get; set; } = 1; + + [JsonPropertyName("mode")] + [YamlMember(Alias = "mode")] + public string? Mode { get; set; } + + [JsonPropertyName("function")] + [YamlMember(Alias = "function")] + public string? Function { get; set; } + + [JsonPropertyName("cards")] + [YamlMember(Alias = "cards")] + public int[]? Cards { get; set; } + + [JsonPropertyName("min")] + [YamlMember(Alias = "min")] + public int? Min { get; set; } + + [JsonPropertyName("filterOrder")] + [YamlMember(Alias = "filterOrder")] + public int? FilterOrder { get; set; } + + [JsonPropertyName("edition")] + [YamlMember(Alias = "edition")] + public string? Edition { get; set; } + + [JsonPropertyName("stickers")] + [YamlMember(Alias = "stickers")] + public string[]? Stickers { get; set; } + + [JsonPropertyName("suit")] + [YamlMember(Alias = "suit")] + public string? Suit { get; set; } + + [JsonPropertyName("rank")] + [YamlMember(Alias = "rank")] + public string? Rank { get; set; } + + [JsonPropertyName("seal")] + [YamlMember(Alias = "seal")] + public string? Seal { get; set; } + + [JsonPropertyName("enhancement")] + [YamlMember(Alias = "enhancement")] + public string? Enhancement { get; set; } + + [JsonPropertyName("sources")] + [YamlMember(Alias = "sources")] + public SourcesConfig? Sources { get; set; } + + [JsonPropertyName("packSlots")] + [YamlMember(Alias = "packSlots")] + public int[]? PackSlots { get; set; } + + [JsonPropertyName("shopSlots")] + [YamlMember(Alias = "shopSlots")] + public int[]? ShopSlots { get; set; } + + [JsonPropertyName("requireMega")] + [YamlMember(Alias = "requireMega")] + public bool? RequireMega { get; set; } + + [JsonPropertyName("tags")] + [YamlMember(Alias = "tags")] + public bool? Tags { get; set; } + + [JsonPropertyName("eventType")] + [YamlMember(Alias = "eventType")] + public string? EventType { get; set; } + + [JsonPropertyName("rolls")] + [YamlMember(Alias = "rolls")] + public int[]? Rolls { get; set; } + } + + public static bool TryLoadFromJsonFile( + string jsonPath, + [NotNullWhen(true)] out MotelyJsonConfig? config) => + TryLoadFromJsonFile(jsonPath, out config, out _); + + public static bool TryLoadFromJsonFile( + string jsonPath, + [NotNullWhen(true)] out MotelyJsonConfig? config, + out string? error) + { + config = null; + error = null; + + if (!File.Exists(jsonPath)) + { + error = $"File not found: {jsonPath}"; + return false; + } + + try + { + var json = File.ReadAllText(jsonPath); + config = JsonSerializer.Deserialize( + json, + MotelyJsonSerializerContext.Default.MotelyJsonConfig); + + if (config is null) + { + error = "Deserialized config was null."; + return false; + } + return true; + } + catch (Exception ex) + { + error = ex.Message; + return false; + } + } +} diff --git a/src/BalatroSeedOracle/Motely/MotelyJsonConfigYaml.cs b/src/BalatroSeedOracle/Motely/MotelyJsonConfigYaml.cs new file mode 100644 index 00000000..35a1e9e0 --- /dev/null +++ b/src/BalatroSeedOracle/Motely/MotelyJsonConfigYaml.cs @@ -0,0 +1,89 @@ +using System; +using System.IO; +using System.Text; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace Motely.Filters; + +// BSO-owned helper that mirrors the parts of upstream JamlConfigLoader BSO used to +// call ("TryLoadFromJamlString" / "TryLoadFromJaml") but produces the BSO +// MotelyJsonConfig DTO. The real upstream loader now produces JamlConfig instead. +// We keep this seam in place so existing UI / cache / filter-browser code keeps +// compiling. Conversion MotelyJsonConfig → JamlConfig happens at the search +// boundary, not here. +public static class MotelyJsonConfigYaml +{ + private static IDeserializer CreateDeserializer() => + new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); + + private static ISerializer CreateSerializer() => + new SerializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull) + .Build(); + + public static bool TryLoad(string jaml, out MotelyJsonConfig? config, out string? error) + { + config = null; + error = null; + if (string.IsNullOrWhiteSpace(jaml)) + { + error = "JAML content is empty."; + return false; + } + + try + { + config = CreateDeserializer().Deserialize(jaml); + if (config is null) + { + error = "Deserialized config was null."; + return false; + } + return true; + } + catch (Exception ex) + { + error = ex.Message; + return false; + } + } + + public static bool TryLoadFromFile(string path, out MotelyJsonConfig? config, out string? error) + { + config = null; + error = null; + if (!File.Exists(path)) + { + error = $"File not found: {path}"; + return false; + } + return TryLoad(File.ReadAllText(path), out config, out error); + } + + public static string Serialize(MotelyJsonConfig config) => + CreateSerializer().Serialize(config); +} + + +// Minimal JamlFormatter replacement used by the JAML editor tab. Upstream lived in +// `Motely.Filters.JamlFormatter`; BSO keeps a smaller version here that round-trips +// MotelyJsonConfig through YAML for the "format current document" command. +public static class JamlFormatter +{ + public static string Format(MotelyJsonConfig config) => + MotelyJsonConfigYaml.Serialize(config); + + public static string Format(string jaml) + { + if (string.IsNullOrWhiteSpace(jaml)) + return jaml; + if (!MotelyJsonConfigYaml.TryLoad(jaml, out var cfg, out _) || cfg is null) + return jaml; + return MotelyJsonConfigYaml.Serialize(cfg); + } +} diff --git a/src/BalatroSeedOracle/Motely/MotelyJsonSerializerContext.cs b/src/BalatroSeedOracle/Motely/MotelyJsonSerializerContext.cs new file mode 100644 index 00000000..fdf6e40f --- /dev/null +++ b/src/BalatroSeedOracle/Motely/MotelyJsonSerializerContext.cs @@ -0,0 +1,21 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Motely.Filters; + +namespace Motely.Filters; + +// AOT source-gen context for MotelyJsonConfig. Lives in BSO now that the type was +// removed from upstream Motely; keeps the same accessor name BSO already uses +// (MotelyJsonSerializerContext.Default.MotelyJsonConfig). +[JsonSourceGenerationOptions( + WriteIndented = true, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true)] +[JsonSerializable(typeof(MotelyJsonConfig))] +[JsonSerializable(typeof(MotelyJsonConfig.MotelyJsonFilterClause))] +[JsonSerializable(typeof(SourcesConfig))] +[JsonSerializable(typeof(MotelyFilterDefaults))] +public partial class MotelyJsonSerializerContext : JsonSerializerContext { } diff --git a/src/BalatroSeedOracle/Motely/MotelySearchOrchestrator.cs b/src/BalatroSeedOracle/Motely/MotelySearchOrchestrator.cs new file mode 100644 index 00000000..cbfe46ab --- /dev/null +++ b/src/BalatroSeedOracle/Motely/MotelySearchOrchestrator.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BalatroSeedOracle.Helpers; +using Motely.Filters; + +namespace Motely.Executors; + +// Bridge between BSO's MotelyJsonConfig and the new MotelyJAML search pipeline. +// Converts to JamlConfig via JamlConfigBridge, compiles a JamlSearchPlan, and +// wraps the resulting IMotelySearch so BSO sees the same IMotelySearchContext +// shape it always has. +public static class MotelySearchOrchestrator +{ + public static IMotelySearchContext LaunchWithContext( + MotelyJsonConfig config, + JsonSearchParams parameters, + bool useInMemoryStorage = false) + { + ArgumentNullException.ThrowIfNull(config); + ArgumentNullException.ThrowIfNull(parameters); + + if (!JamlConfigBridge.TryConvertToJaml(config, out var jaml, out var error, out _) || jaml is null) + { + DebugLogger.LogError("MotelySearchOrchestrator", $"JAML conversion failed: {error}"); + return new FailedMotelySearchContext(config, error ?? "JAML conversion failed"); + } + + try + { + var plan = JamlSearchBuilder.CreatePlan(jaml); + var settings = plan.Settings + .WithThreadCount(Math.Max(1, parameters.Threads)) + .WithBatchCharacterCount(Math.Clamp(parameters.BatchCharCount, 1, 7)); + + if (parameters.StartBatch > 0 && parameters.StartBatch <= long.MaxValue) + settings.WithStartBatchIndex((long)parameters.StartBatch); + if (parameters.EndBatch > 0 && parameters.EndBatch <= long.MaxValue) + settings.WithEndBatchIndex((long)parameters.EndBatch); + + if (!string.IsNullOrWhiteSpace(parameters.SpecificSeed)) + settings.WithListSearch(new[] { parameters.SpecificSeed.ToUpperInvariant() }); + else if (parameters.RandomSeeds > 0) + settings.WithRandomSearch(parameters.RandomSeeds); + else + settings.WithSequentialSearch(); + + var ctx = new LiveMotelySearchContext(config, plan); + settings.WithScoredResultCallback(ctx.OnResult); + settings.WithProgressCallback(ctx.OnProgress); + ctx.AttachSearch(settings.CreateSearch()); + return ctx; + } + catch (Exception ex) + { + DebugLogger.LogError("MotelySearchOrchestrator", $"Failed to build search: {ex.Message}"); + return new FailedMotelySearchContext(config, ex.Message); + } + } + + public static void SetRepository(object _) { /* legacy: persistence layer removed */ } +} + +internal sealed class LiveMotelySearchContext : IMotelySearchContext +{ + private readonly MotelyJsonConfig _config; + private readonly JamlSearchPlan _plan; + private readonly ConcurrentQueue _results = new(); + private readonly CancellationTokenSource _cts = new(); + private IMotelySearch? _search; + private Task? _runTask; + private DateTime _startedAt = DateTime.UtcNow; + private long _matchingSeeds; + private MotelySearchStatus _status = MotelySearchStatus.Created; + + public LiveMotelySearchContext(MotelyJsonConfig config, JamlSearchPlan plan) + { + _config = config; + _plan = plan; + SearchId = Guid.NewGuid().ToString("N"); + FilterId = _config.Name ?? SearchId; + } + + public void AttachSearch(IMotelySearch search) => _search = search; + + public string SearchId { get; } + public string FilterId { get; } + public MotelySearchStatus Status => _status; + public TimeSpan ElapsedTime => + _search is not null ? TimeSpan.FromMilliseconds(_search.ElapsedMs) : DateTime.UtcNow - _startedAt; + public long TotalSeedsSearched => _search?.TotalSeedsSearched ?? 0; + public long MatchingSeeds => Interlocked.Read(ref _matchingSeeds); + public long FilteredSeeds => _search?.FilteredSeeds ?? 0; + public int ResultCount => _results.Count; + public IReadOnlyList ColumnNames => _plan.TallyLabels; + + public IList GetResults(int offset, int count) + { + var snapshot = _results.ToArray(); + if (offset >= snapshot.Length) return Array.Empty(); + return snapshot + .OrderByDescending(r => r.Score) + .Skip(offset) + .Take(count) + .ToArray(); + } + + public IList GetTopResults(int limit = 1000) => + _results.ToArray() + .OrderByDescending(r => r.Score) + .Take(limit) + .ToArray(); + + public void ExportTo(string outputPath) => + throw new NotSupportedException("Export sink (Motely.DataLake) is not yet wired in."); + + public void Start() + { + if (_search is null) return; + if (_status != MotelySearchStatus.Created && _status != MotelySearchStatus.Paused) return; + _status = MotelySearchStatus.Running; + _startedAt = DateTime.UtcNow; + _runTask = Task.Run(async () => + { + try + { + await _search.RunSearchAsync(_cts.Token).ConfigureAwait(false); + _status = MotelySearchStatus.Completed; + } + catch (OperationCanceledException) + { + _status = MotelySearchStatus.Cancelled; + } + catch (Exception ex) + { + DebugLogger.LogError("LiveMotelySearchContext", $"Search threw: {ex.Message}"); + _status = MotelySearchStatus.Failed; + } + }); + } + + public void Pause() => _status = MotelySearchStatus.Paused; // engine has no pause primitive yet + public void Cancel() + { + try { _cts.Cancel(); } catch { /* already disposed */ } + try { _search?.Cancel(); } catch { /* already done */ } + } + + public void Dispose() + { + Cancel(); + _search?.Dispose(); + _cts.Dispose(); + } + + internal void OnResult(MotelySeedScoreTally tally) + { + var tallies = tally.Tally; + var tallyInts = new int[tallies.Length]; + for (int i = 0; i < tallies.Length; i++) tallyInts[i] = tallies[i]; + _results.Enqueue(new MotelySearchResultRow + { + Seed = tally.Seed, + Score = tally.Score, + Tallies = tallyInts, + }); + Interlocked.Increment(ref _matchingSeeds); + } + + internal void OnProgress(MotelyProgress _) { /* placeholder — UI polls via TotalSeedsSearched */ } +} + +internal sealed class FailedMotelySearchContext : IMotelySearchContext +{ + private readonly MotelyJsonConfig _config; + public FailedMotelySearchContext(MotelyJsonConfig config, string reason) + { + _config = config; + SearchId = Guid.NewGuid().ToString("N"); + FilterId = _config.Name ?? SearchId; + FailureReason = reason; + } + public string SearchId { get; } + public string FilterId { get; } + public string FailureReason { get; } + public MotelySearchStatus Status => MotelySearchStatus.Failed; + public TimeSpan ElapsedTime => TimeSpan.Zero; + public long TotalSeedsSearched => 0; + public long MatchingSeeds => 0; + public long FilteredSeeds => 0; + public int ResultCount => 0; + public IReadOnlyList ColumnNames { get; } = Array.Empty(); + public IList GetResults(int offset, int count) => Array.Empty(); + public IList GetTopResults(int limit = 1000) => Array.Empty(); + public void ExportTo(string outputPath) => throw new InvalidOperationException(FailureReason); + public void Start() { } + public void Pause() { } + public void Cancel() { } + public void Dispose() { } +} + +// Restored to satisfy DesktopAppExtensions wiring. Holds a global thread budget; +// the actual multi-search coordination lived in Motely.Orchestration and is being +// reintroduced incrementally alongside the JamlSearchBuilder integration. +public sealed class MultiSearchManager +{ + public static MultiSearchManager Instance { get; } = new(); + public int TotalThreads { get; private set; } = Environment.ProcessorCount; + public void SetTotalThreads(int threads) => TotalThreads = Math.Max(1, threads); + public void StopAll() { /* no active multi-search registry yet */ } +} diff --git a/src/BalatroSeedOracle/Motely/SearchOptionsDto.cs b/src/BalatroSeedOracle/Motely/SearchOptionsDto.cs new file mode 100644 index 00000000..1133b323 --- /dev/null +++ b/src/BalatroSeedOracle/Motely/SearchOptionsDto.cs @@ -0,0 +1,22 @@ +namespace Motely; + +// Lifted from upstream Motely (removed at commit a653e3b0). BSO still uses it as the +// transport object between its search UI and the local/remote search engines. +public sealed class SearchOptionsDto +{ + public int? ThreadCount { get; set; } + public int? BatchSize { get; set; } + public long? StartBatch { get; set; } + public long? EndBatch { get; set; } + public double? StartPercent { get; set; } + public double? EndPercent { get; set; } + public string? StartSeed { get; set; } + public string? Cutoff { get; set; } + + public string? SeedList { get; set; } + public string? Keyword { get; set; } + public string? Padding { get; set; } + public int? RandomSeeds { get; set; } + public bool? Palindrome { get; set; } + public string? SpecificSeed { get; set; } +} diff --git a/src/BalatroSeedOracle/Services/ActiveSearchContext.cs b/src/BalatroSeedOracle/Services/ActiveSearchContext.cs index e4a6fe7c..45772329 100644 --- a/src/BalatroSeedOracle/Services/ActiveSearchContext.cs +++ b/src/BalatroSeedOracle/Services/ActiveSearchContext.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using BalatroSeedOracle.Models; using Motely; +using Motely.Executors; using Motely.Filters; using DebugLogger = BalatroSeedOracle.Helpers.DebugLogger; diff --git a/src/BalatroSeedOracle/Services/IApiHostService.cs b/src/BalatroSeedOracle/Services/IApiHostService.cs deleted file mode 100644 index c378148e..00000000 --- a/src/BalatroSeedOracle/Services/IApiHostService.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System; -using System.Threading.Tasks; - -namespace BalatroSeedOracle.Services; - -/// -/// Platform-agnostic interface for hosting the Motely API server. -/// Desktop provides real implementation, Browser provides no-op stub. -/// -public interface IApiHostService -{ - /// - /// Whether API hosting is supported on this platform - /// - bool IsSupported { get; } - - /// - /// Whether the server is currently running - /// - bool IsRunning { get; } - - /// - /// Current server URL when running - /// - string ServerUrl { get; } - - /// - /// Event fired when log messages are generated - /// - event Action? LogMessage; - - /// - /// Event fired when server status changes - /// - event Action? StatusChanged; - - /// - /// Start the API server on the specified port - /// - Task StartAsync(int port); - - /// - /// Stop the API server - /// - Task StopAsync(); -} diff --git a/src/BalatroSeedOracle/Services/IRestoreActiveSearchesProvider.cs b/src/BalatroSeedOracle/Services/IRestoreActiveSearchesProvider.cs deleted file mode 100644 index 807e6a27..00000000 --- a/src/BalatroSeedOracle/Services/IRestoreActiveSearchesProvider.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace BalatroSeedOracle.Services; - -/// -/// Abstraction for restoring active searches from the sequential library (Motely.DB). -/// Desktop implements via Motely.DB.SequentialLibrary; Browser does not register. -/// -public interface IRestoreActiveSearchesProvider -{ - Task> RestoreAsync(string jamlFiltersDir); -} diff --git a/src/BalatroSeedOracle/Services/ISequentialLibraryInitializer.cs b/src/BalatroSeedOracle/Services/ISequentialLibraryInitializer.cs deleted file mode 100644 index aae63c53..00000000 --- a/src/BalatroSeedOracle/Services/ISequentialLibraryInitializer.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace BalatroSeedOracle.Services; - -/// -/// Abstraction for initializing the sequential library root (Motely.DB). -/// Desktop implements via Motely.DB.SequentialLibrary; Browser does not register. -/// -public interface ISequentialLibraryInitializer -{ - void SetLibraryRoot(string path); -} diff --git a/src/BalatroSeedOracle/Services/MinigameDownloadService.cs b/src/BalatroSeedOracle/Services/MinigameDownloadService.cs index 2c798a02..24aacaf5 100644 --- a/src/BalatroSeedOracle/Services/MinigameDownloadService.cs +++ b/src/BalatroSeedOracle/Services/MinigameDownloadService.cs @@ -36,7 +36,7 @@ public MinigameDownloadService() // Return raw config - the ViewModel will handle the "Pick Seed by Day" logic // The JAML contains ALL seeds for the season. // We need to parse JAML here. Since JamlConfigLoader might be available: - if (JamlConfigLoader.TryLoadFromJamlString(content, out var config, out _)) + if (Motely.JamlConfigLoader.TryLoadFromJamlString(content, out var config, out _)) { return config; } diff --git a/src/BalatroSeedOracle/Services/SearchManager.cs b/src/BalatroSeedOracle/Services/SearchManager.cs index 95209856..6d33859d 100644 --- a/src/BalatroSeedOracle/Services/SearchManager.cs +++ b/src/BalatroSeedOracle/Services/SearchManager.cs @@ -227,72 +227,22 @@ public void RemoveSearch(string searchId) } /// - /// Initialize the SequentialLibrary for search persistence. - /// Call this at app startup (Desktop only). Motely.DB owns the implementation. + /// Library persistence was provided by Motely.DB, which has been removed. + /// No-op until JamlSearchBuilder-based persistence is wired up. /// public void InitializeLibrary(string seedsPath) { - if (!_platformServices.SupportsFileSystem) - { - DebugLogger.Log("SearchManager", "Skipping library initialization (browser)"); - return; - } - - try - { - var initializer = ServiceHelper.GetService(); - if (initializer != null) - { - initializer.SetLibraryRoot(seedsPath); - DebugLogger.Log("SearchManager", $"SequentialLibrary initialized at: {seedsPath}"); - } - else - { - DebugLogger.Log("SearchManager", "SequentialLibrary not available (browser build)"); - } - } - catch (Exception ex) - { - DebugLogger.LogError("SearchManager", $"Failed to initialize library: {ex.Message}"); - } + DebugLogger.Log("SearchManager", $"InitializeLibrary({seedsPath}) — sequential library not wired in this build"); } /// - /// Restore active searches from the SequentialLibrary. - /// Returns metadata for searches that need UI widgets created. - /// Call this at app startup after InitializeLibrary. Motely.DB owns the implementation. + /// Same story as : persistent active-search + /// restoration depended on Motely.DB and is parked until the new search + /// engine grows back a result sink BSO can read. /// - public async Task> RestoreActiveSearchesAsync(string jamlFiltersDir) + public Task> RestoreActiveSearchesAsync(string jamlFiltersDir) { - var restored = new List(); - - if (!_platformServices.SupportsFileSystem) - { - DebugLogger.Log("SearchManager", "Skipping search restoration (browser)"); - return restored; - } - - try - { - var provider = ServiceHelper.GetService(); - if (provider == null) - { - DebugLogger.Log( - "SearchManager", - "RestoreActiveSearchesProvider not available (browser build)" - ); - return restored; - } - - restored = await provider.RestoreAsync(jamlFiltersDir).ConfigureAwait(false); - DebugLogger.Log("SearchManager", $"Found {restored.Count} active searches to restore"); - } - catch (Exception ex) - { - DebugLogger.LogError("SearchManager", $"Error during search restoration: {ex.Message}"); - } - - return restored; + return Task.FromResult(new List()); } /// diff --git a/src/BalatroSeedOracle/ViewModels/AnalyzerViewModel.cs b/src/BalatroSeedOracle/ViewModels/AnalyzerViewModel.cs index e1b3eaa0..2fa789f3 100644 --- a/src/BalatroSeedOracle/ViewModels/AnalyzerViewModel.cs +++ b/src/BalatroSeedOracle/ViewModels/AnalyzerViewModel.cs @@ -345,7 +345,7 @@ public List CurrentShopItemsRaw var ante = GetCurrentAnte(); if (ante is null) return []; - return ante.ShopQueue.ToList(); + return ante.ShopQueue.Select(x => (MotelyItem)x).ToList(); } } @@ -408,7 +408,7 @@ public List CurrentPacks { Name = FormatUtils.FormatPackName(pack.Type), Items = pack.Items.Select(item => FormatUtils.FormatItem(item)).ToList(), - RawItems = pack.Items.ToList(), + RawItems = pack.Items.Select(item => (MotelyItem)item).ToList(), PackType = pack.Type.GetPackType(), }) .ToList(); diff --git a/src/BalatroSeedOracle/ViewModels/ApiHostWidgetViewModel.cs b/src/BalatroSeedOracle/ViewModels/ApiHostWidgetViewModel.cs deleted file mode 100644 index cb030d46..00000000 --- a/src/BalatroSeedOracle/ViewModels/ApiHostWidgetViewModel.cs +++ /dev/null @@ -1,229 +0,0 @@ -using System; -using System.Threading.Tasks; -using Avalonia.Threading; -using BalatroSeedOracle.Helpers; -using BalatroSeedOracle.Services; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; - -namespace BalatroSeedOracle.ViewModels; - -/// -/// Server status for styling via pseudo-classes -/// -public enum ServerStatus -{ - Stopped, - Starting, - Running, - Stopping, - Failed, - Unsupported, -} - -/// -/// ViewModel for the API Host Widget - hosts the Motely API server within BSO. -/// Uses IApiHostService for platform abstraction. Service is null on unsupported platforms (e.g., Browser). -/// -public partial class ApiHostWidgetViewModel : BaseWidgetViewModel -{ - private readonly IApiHostService? _apiHostService; - - [ObservableProperty] - private bool _isServerRunning; - - [ObservableProperty] - private ServerStatus _currentStatus = ServerStatus.Stopped; - - [ObservableProperty] - private string _serverStatusText = "Stopped"; - - [ObservableProperty] - private string _serverUrl = "http://localhost:3141/"; - - [ObservableProperty] - private string _logText = ""; - - [ObservableProperty] - private int _port = 3141; - - [ObservableProperty] - private bool _isSupported = true; - - public ApiHostWidgetViewModel( - IApiHostService? apiHostService = null, - WidgetPositionService? positionService = null - ) - : base(positionService) - { - _apiHostService = apiHostService; - - WidgetTitle = "API Host"; - WidgetIcon = "ServerNetwork"; - IsMinimized = true; - - // Service null = platform doesn't support API hosting - IsSupported = _apiHostService?.IsSupported ?? false; - - if (!IsSupported) - { - CurrentStatus = ServerStatus.Unsupported; - ServerStatusText = "N/A (Browser)"; - LogMessage("API hosting not available on this platform. Use Desktop version."); - } - - // Subscribe to service events (only if service exists) - if (_apiHostService is not null) - { - _apiHostService.LogMessage += OnServiceLogMessage; - _apiHostService.StatusChanged += OnServiceStatusChanged; - } - } - - private void OnServiceLogMessage(string message) - { - LogMessage(message); - } - - private void OnServiceStatusChanged(bool isRunning) - { - Dispatcher.UIThread.Post(() => - { - IsServerRunning = isRunning; - CurrentStatus = isRunning ? ServerStatus.Running : ServerStatus.Stopped; - ServerStatusText = isRunning ? "Running" : "Stopped"; - ServerUrl = _apiHostService?.ServerUrl ?? ""; - }); - } - - [RelayCommand] - private async Task StartServerAsync() - { - if (!IsSupported || IsServerRunning || _apiHostService is null) - return; - - try - { - CurrentStatus = ServerStatus.Starting; - ServerStatusText = "Starting..."; - ServerUrl = $"http://localhost:{Port}/"; - - await _apiHostService.StartAsync(Port); - } - catch (Exception ex) - { - CurrentStatus = ServerStatus.Failed; - ServerStatusText = "Failed"; - IsServerRunning = false; - LogMessage($"Failed to start: {ex.Message}"); - DebugLogger.LogError($"[ApiHost] Failed to start API server: {ex.Message}"); - } - } - - [RelayCommand] - private async Task StopServerAsync() - { - if (!IsSupported || !IsServerRunning || _apiHostService is null) - return; - - try - { - CurrentStatus = ServerStatus.Stopping; - ServerStatusText = "Stopping..."; - - await _apiHostService.StopAsync(); - } - catch (Exception ex) - { - LogMessage($"Error stopping: {ex.Message}"); - CurrentStatus = ServerStatus.Stopped; - ServerStatusText = "Stopped"; - IsServerRunning = false; - } - } - - [RelayCommand] - private void OpenInBrowser() - { - if (!IsServerRunning) - return; - - try - { - var psi = new System.Diagnostics.ProcessStartInfo(ServerUrl) { UseShellExecute = true }; - System.Diagnostics.Process.Start(psi); - LogMessage($"Opened browser: {ServerUrl}"); - } - catch (Exception ex) - { - LogMessage($"Failed to open browser: {ex.Message}"); - } - } - - [RelayCommand] - private async Task CopyUrlAsync() - { - try - { - var topLevel = Avalonia.Application.Current?.ApplicationLifetime - is Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop - ? desktop.MainWindow - : null; - - if (topLevel?.Clipboard is { } clipboard) - { - await clipboard.SetTextAsync(ServerUrl); - LogMessage("Copied URL to clipboard"); - } - } - catch (Exception ex) - { - LogMessage($"Failed to copy: {ex.Message}"); - } - } - - [RelayCommand] - private void ClearLog() - { - LogText = ""; - } - - [RelayCommand] - private void ToggleMinimized() - { - IsMinimized = !IsMinimized; - } - - private void LogMessage(string message) - { - var timestamp = DateTime.Now.ToString("HH:mm:ss"); - var line = $"[{timestamp}] {message}\n"; - - Dispatcher.UIThread.Post(() => - { - LogText += line; - - // Trim log if it gets too long - if (LogText.Length > 5000) - { - LogText = LogText.Substring(LogText.Length - 4000); - } - }); - - DebugLogger.Log($"[ApiHost] {message}"); - } - - public async Task CleanupAsync() - { - if (_apiHostService is not null) - { - _apiHostService.LogMessage -= OnServiceLogMessage; - _apiHostService.StatusChanged -= OnServiceStatusChanged; - } - - if (IsServerRunning) - { - await StopServerAsync(); - } - } -} diff --git a/src/BalatroSeedOracle/ViewModels/BalatroMainMenuViewModel.cs b/src/BalatroSeedOracle/ViewModels/BalatroMainMenuViewModel.cs index fed190d7..518c2978 100644 --- a/src/BalatroSeedOracle/ViewModels/BalatroMainMenuViewModel.cs +++ b/src/BalatroSeedOracle/ViewModels/BalatroMainMenuViewModel.cs @@ -24,15 +24,9 @@ public partial class BalatroMainMenuViewModel : ObservableObject private readonly Func _analyzeModalFactory; private readonly IAudioManager? _audioManager; private readonly EventFXService? _eventFXService; - private readonly IApiHostService? _apiHostService; private readonly IPlatformServices? _platformServices; private Action? _audioAnalysisHandler; - /// - /// ViewModel for the API Host widget - owned by parent, bound via XAML DataContext - /// - public ApiHostWidgetViewModel? ApiHostWidgetViewModel { get; } - // Effect source tracking private int _shadowFlickerSource = 0; private int _spinSource = 0; @@ -219,7 +213,6 @@ public BalatroMainMenuViewModel( FiltersModalViewModel filtersModalViewModel, CreditsModalViewModel creditsModalViewModel, Func analyzeModalFactory, - IApiHostService? apiHostService = null, IAudioManager? audioManager = null, EventFXService? eventFXService = null, WidgetPositionService? widgetPositionService = null, @@ -233,20 +226,10 @@ public BalatroMainMenuViewModel( SearchModalViewModel = searchModalViewModel; FiltersModalViewModel = filtersModalViewModel; CreditsModalViewModel = creditsModalViewModel; - _apiHostService = apiHostService; _audioManager = audioManager; _eventFXService = eventFXService; _platformServices = platformServices; - // Create child widget ViewModels (owned by parent, bound via XAML) - if (_apiHostService is not null) - { - ApiHostWidgetViewModel = new ApiHostWidgetViewModel( - _apiHostService, - widgetPositionService - ); - } - // Load settings LoadSettings(); } diff --git a/src/BalatroSeedOracle/ViewModels/FilterTabs/JamlEditorTabViewModel.cs b/src/BalatroSeedOracle/ViewModels/FilterTabs/JamlEditorTabViewModel.cs index 8160e043..e66d2122 100644 --- a/src/BalatroSeedOracle/ViewModels/FilterTabs/JamlEditorTabViewModel.cs +++ b/src/BalatroSeedOracle/ViewModels/FilterTabs/JamlEditorTabViewModel.cs @@ -11,7 +11,6 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Motely.Filters; -using MotelyJaml; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; @@ -115,7 +114,7 @@ private void ValidateAndPreview() try { - var deserializer = new StaticDeserializerBuilder(new MotelyJamlStaticContext()) + var deserializer = new DeserializerBuilder() .WithNamingConvention(NullNamingConvention.Instance) .IgnoreUnmatchedProperties() .Build(); @@ -433,7 +432,7 @@ private async Task SyncToVisual() try { - var deserializer = new StaticDeserializerBuilder(new MotelyJamlStaticContext()) + var deserializer = new DeserializerBuilder() .WithNamingConvention(NullNamingConvention.Instance) .IgnoreUnmatchedProperties() .Build(); diff --git a/src/BalatroSeedOracle/Views/Modals/ToolsModal.axaml.cs b/src/BalatroSeedOracle/Views/Modals/ToolsModal.axaml.cs index 542eaa25..604b778d 100644 --- a/src/BalatroSeedOracle/Views/Modals/ToolsModal.axaml.cs +++ b/src/BalatroSeedOracle/Views/Modals/ToolsModal.axaml.cs @@ -16,7 +16,6 @@ namespace BalatroSeedOracle.Views.Modals public partial class ToolsModal : UserControl { private readonly UserProfileService? _userProfileService; - private readonly IApiHostService? _apiHostService; /// Parameterless ctor for XAML loader only. Throws at runtime. Creator must pass dependencies. public ToolsModal() @@ -26,17 +25,15 @@ private ToolsModal(bool throwForDesignTimeOnly) { if (throwForDesignTimeOnly) throw new InvalidOperationException( - "Do not use ToolsModal(). Creator must pass (UserProfileService, IApiHostService)." + "Do not use ToolsModal(). Creator must pass UserProfileService." ); _userProfileService = null; - _apiHostService = null; InitializeComponent(); } - public ToolsModal(UserProfileService? userProfileService, IApiHostService? apiHostService) + public ToolsModal(UserProfileService? userProfileService) { _userProfileService = userProfileService; - _apiHostService = apiHostService; InitializeComponent(); } @@ -100,18 +97,18 @@ private async void OnImportFilesClick(object? sender, RoutedEventArgs e) MotelyJsonConfig? config; if (extension == ".jaml") { - if ( - !Motely.JamlConfigLoader.TryLoadFromJamlString( - text, - out config, - out var parseError - ) - || config == null - ) + try + { + var deserializer = new YamlDotNet.Serialization.DeserializerBuilder() + .IgnoreUnmatchedProperties() + .Build(); + config = deserializer.Deserialize(text); + } + catch (Exception parseEx) { DebugLogger.LogError( "ToolsModal", - $"Failed to parse JAML {storageFile.Name}: {parseError ?? "Unknown error"}" + $"Failed to parse JAML {storageFile.Name}: {parseEx.Message}" ); failCount++; continue; @@ -282,14 +279,7 @@ private void OnBalatroSiteClick(object? sender, RoutedEventArgs e) private void OnWebAppClick(object? sender, RoutedEventArgs e) { - // Use web app in WebView instead of re-creating: prefer running API (BSO at /BSO), else fallback - var url = - _apiHostService != null - && _apiHostService.IsRunning - && !string.IsNullOrWhiteSpace(_apiHostService.ServerUrl) - ? new Uri(new Uri(_apiHostService.ServerUrl.TrimEnd('/')), "BSO/") - : WebAppFallbackUri; - OpenWebViewDialog("Web App", url); + OpenWebViewDialog("Web App", WebAppFallbackUri); } private void OpenWebViewDialog(string title, Uri source)