From ea487cd332e83959298072aff62abb09f05bb6f7 Mon Sep 17 00:00:00 2001 From: Samuel Bouchet Date: Mon, 16 Mar 2026 14:52:40 +0100 Subject: [PATCH] Add Blazor WASM web build for itch.io browser playtesting Compile the game to WebAssembly so it runs entirely client-side in the browser. Uses xterm.js for terminal emulation and Spectre.Console off-screen rendering for full ANSI output fidelity. Saves use localStorage. CI deploys to itch.io via Butler on push to main. --- .github/workflows/deploy-itch.yml | 28 + OpenTheBox.slnx | 1 + README.md | 24 +- src/OpenTheBox.Web/OpenTheBox.Web.csproj | 35 + src/OpenTheBox.Web/Program.cs | 25 + src/OpenTheBox.Web/WebGameHost.cs | 1359 +++++++++++++++++ src/OpenTheBox.Web/WebSaveManager.cs | 95 ++ src/OpenTheBox.Web/WebTerminal.cs | 384 +++++ src/OpenTheBox.Web/wwwroot/css/terminal.css | 90 ++ src/OpenTheBox.Web/wwwroot/index.html | 30 + .../wwwroot/js/terminal-interop.js | 68 + src/OpenTheBox/Adventures/AdventureEngine.cs | 80 + src/OpenTheBox/Data/ContentRegistry.cs | 43 + .../Localization/LocalizationManager.cs | 16 + src/OpenTheBox/Persistence/SaveJsonContext.cs | 2 +- src/OpenTheBox/Rendering/UnicodeSupport.cs | 7 + 16 files changed, 2284 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/deploy-itch.yml create mode 100644 src/OpenTheBox.Web/OpenTheBox.Web.csproj create mode 100644 src/OpenTheBox.Web/Program.cs create mode 100644 src/OpenTheBox.Web/WebGameHost.cs create mode 100644 src/OpenTheBox.Web/WebSaveManager.cs create mode 100644 src/OpenTheBox.Web/WebTerminal.cs create mode 100644 src/OpenTheBox.Web/wwwroot/css/terminal.css create mode 100644 src/OpenTheBox.Web/wwwroot/index.html create mode 100644 src/OpenTheBox.Web/wwwroot/js/terminal-interop.js diff --git a/.github/workflows/deploy-itch.yml b/.github/workflows/deploy-itch.yml new file mode 100644 index 0000000..b5115ce --- /dev/null +++ b/.github/workflows/deploy-itch.yml @@ -0,0 +1,28 @@ +name: Deploy to itch.io + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Publish Blazor WASM + run: dotnet publish src/OpenTheBox.Web -c Release -o publish + + - name: Deploy to itch.io + uses: manleydev/butler-publish-itchio-action@master + env: + BUTLER_CREDENTIALS: ${{ secrets.BUTLER_API_KEY }} + CHANNEL: html5 + ITCH_GAME: openthebox + ITCH_USER: Lythom + PACKAGE: publish/wwwroot diff --git a/OpenTheBox.slnx b/OpenTheBox.slnx index 0698c35..f1ed150 100644 --- a/OpenTheBox.slnx +++ b/OpenTheBox.slnx @@ -1,6 +1,7 @@ + diff --git a/README.md b/README.md index 73d79b0..caa8f35 100644 --- a/README.md +++ b/README.md @@ -72,12 +72,24 @@ dotnet run --project src/OpenTheBox ## Distribute +### Desktop (self-contained) + ```powershell dotnet publish src/OpenTheBox -c Release -r win-x64 -o "builds/$(Get-Date -Format yyyyMMdd_HHmmss)_win" -dotnet publish src/OpenTheBox -c Release -r linux-x64-o "builds/$(Get-Date -Format yyyyMMdd_HHmmss)_linux" +dotnet publish src/OpenTheBox -c Release -r linux-x64 -o "builds/$(Get-Date -Format yyyyMMdd_HHmmss)_linux" ``` -This produces a self-contained single-file executable in `publish//`. The target machine does **not** need .NET installed. Distribute the entire folder (exe + `content/`). +This produces a self-contained single-file executable. The target machine does **not** need .NET installed. Distribute the entire folder (exe + `content/`). + +### Web (Blazor WASM → itch.io) + +```powershell +dotnet publish src/OpenTheBox.Web -c Release -o publish +``` + +The `publish/wwwroot/` folder contains a static site playable in any browser. Upload it to itch.io as an HTML5 project. + +**CI/CD:** The GitHub Actions workflow `.github/workflows/deploy-itch.yml` automatically publishes to itch.io (user: Lythom, channel: html5) on every push to `main`. Requires the `BUTLER_API_KEY` secret configured in the repository. ## Tests @@ -99,6 +111,14 @@ openthebox/ | +-- Adventures/ # Loreline integration | +-- Persistence/ # Save/Load | +-- Localization/ # JSON string manager ++-- src/OpenTheBox.Web/ +| +-- Program.cs # Blazor WASM entry point +| +-- WebGameHost.cs # Async game loop for browser +| +-- WebTerminal.cs # xterm.js ↔ C# bridge +| +-- WebSaveManager.cs # localStorage saves +| +-- wwwroot/ # HTML, JS, CSS served to browser ++-- .github/workflows/ +| +-- deploy-itch.yml # CI: build WASM + Butler push to itch.io +-- content/ | +-- data/ # boxes.json, items.json, interactions.json, recipes.json | +-- strings/ # en.json, fr.json diff --git a/src/OpenTheBox.Web/OpenTheBox.Web.csproj b/src/OpenTheBox.Web/OpenTheBox.Web.csproj new file mode 100644 index 0000000..b19de00 --- /dev/null +++ b/src/OpenTheBox.Web/OpenTheBox.Web.csproj @@ -0,0 +1,35 @@ + + + + net10.0 + enable + enable + OpenTheBox.Web + + + + + + + + + + + + + + + PreserveNewest + wwwroot\content\data\%(RecursiveDir)%(Filename)%(Extension) + + + PreserveNewest + wwwroot\content\adventures\%(RecursiveDir)%(Filename)%(Extension) + + + PreserveNewest + wwwroot\content\strings\%(RecursiveDir)%(Filename)%(Extension) + + + + diff --git a/src/OpenTheBox.Web/Program.cs b/src/OpenTheBox.Web/Program.cs new file mode 100644 index 0000000..8158da4 --- /dev/null +++ b/src/OpenTheBox.Web/Program.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using Microsoft.JSInterop; + +namespace OpenTheBox.Web; + +public static class Program +{ + public static async Task Main(string[] args) + { + var builder = WebAssemblyHostBuilder.CreateDefault(args); + + builder.Services.AddScoped(sp => new HttpClient + { + BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) + }); + + var host = builder.Build(); + + var js = host.Services.GetRequiredService(); + var http = host.Services.GetRequiredService(); + + var gameHost = new WebGameHost(js, http); + await gameHost.RunAsync(); + } +} diff --git a/src/OpenTheBox.Web/WebGameHost.cs b/src/OpenTheBox.Web/WebGameHost.cs new file mode 100644 index 0000000..079e718 --- /dev/null +++ b/src/OpenTheBox.Web/WebGameHost.cs @@ -0,0 +1,1359 @@ +using Microsoft.JSInterop; +using OpenTheBox.Adventures; +using OpenTheBox.Core; +using OpenTheBox.Core.Crafting; +using OpenTheBox.Core.Enums; +using OpenTheBox.Core.Items; +using OpenTheBox.Data; +using OpenTheBox.Localization; +using OpenTheBox.Rendering; +using OpenTheBox.Rendering.Panels; +using OpenTheBox.Simulation; +using OpenTheBox.Simulation.Actions; +using OpenTheBox.Simulation.Events; +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace OpenTheBox.Web; + +/// +/// Async game host for the Blazor WASM build. Mirrors Program.cs but uses +/// WebTerminal for I/O instead of System.Console, and HttpClient for content loading. +/// +public sealed class WebGameHost +{ + private readonly IJSRuntime _js; + private readonly HttpClient _http; + private WebTerminal _terminal = null!; + private WebSaveManager _saveManager = null!; + + private GameState _state = null!; + private ContentRegistry _registry = null!; + private LocalizationManager _loc = null!; + private GameSimulation _simulation = null!; + private RenderContext _renderContext = null!; + private CraftingEngine _craftingEngine = null!; + private bool _appRunning = true; + private bool _gameRunning; + private DateTime _sessionStart; + + // Pre-loaded content for adventures (theme -> (script, translation?)) + private Dictionary _adventureScripts = new(); + private Dictionary _adventureTranslations = new(); + + // Pre-loaded localization strings + private Dictionary _locStrings = new(); + + public WebGameHost(IJSRuntime js, HttpClient http) + { + _js = js; + _http = http; + } + + public async Task RunAsync() + { + UnicodeSupport.Initialize(); + + _terminal = new WebTerminal(_js); + _saveManager = new WebSaveManager(_js); + _loc = new LocalizationManager(Locale.EN); + + // Pre-load all content + await LoadAllContentAsync(); + + _renderContext = new RenderContext(); + + // Initialize terminal + await _terminal.InitAsync(); + + await MainMenuLoop(); + } + + // ── Content loading ────────────────────────────────────────────────── + + private async Task LoadAllContentAsync() + { + // Load localization strings + try + { + string enJson = await _http.GetStringAsync("content/strings/en.json"); + _locStrings["en"] = enJson; + _loc.LoadFromString(Locale.EN, enJson); + } + catch (HttpRequestException) { } + + try + { + string frJson = await _http.GetStringAsync("content/strings/fr.json"); + _locStrings["fr"] = frJson; + } + catch (HttpRequestException) { } + + // Pre-load adventure scripts + var themes = Enum.GetValues(); + foreach (var theme in themes) + { + string themeName = theme.ToString().ToLowerInvariant(); + try + { + string script = await _http.GetStringAsync($"content/adventures/{themeName}/intro.lor"); + _adventureScripts[themeName] = script; + } + catch (HttpRequestException) { } + + try + { + string trans = await _http.GetStringAsync($"content/adventures/{themeName}/intro.fr.lor"); + _adventureTranslations[themeName] = trans; + } + catch (HttpRequestException) { } + } + } + + private async Task InitializeGameAsync() + { + string itemsJson = await _http.GetStringAsync("content/data/items.json"); + string boxesJson = await _http.GetStringAsync("content/data/boxes.json"); + string interactionsJson = await _http.GetStringAsync("content/data/interactions.json"); + string recipesJson = await _http.GetStringAsync("content/data/recipes.json"); + + _registry = ContentRegistry.LoadFromStrings(itemsJson, boxesJson, interactionsJson, recipesJson); + _simulation = new GameSimulation(_registry); + _craftingEngine = new CraftingEngine(); + _renderContext = RenderContext.FromGameState(_state); + } + + private void ChangeLocale(Locale locale) + { + string key = locale.ToString().ToLowerInvariant(); + if (_locStrings.TryGetValue(key, out string? json)) + _loc.LoadFromString(locale, json); + else + _loc.Change(locale); // fallback to file-based (will fail gracefully in WASM) + } + + // ── Main menu ──────────────────────────────────────────────────────── + + private async Task MainMenuLoop() + { + var existingSaves = await _saveManager.ListSlotsAsync(); + + if (existingSaves.Count > 0) + { + var recentState = await _saveManager.LoadAsync(existingSaves[0].Name); + if (recentState != null) + { + ChangeLocale(recentState.CurrentLocale); + } + } + else + { + await _terminal.ClearAsync(); + var langOptions = new List { "English", "Français" }; + int langChoice = await _terminal.ShowSelectionAsync("Language / Langue", langOptions, false); + var selectedLocale = langChoice == 0 ? Locale.EN : Locale.FR; + ChangeLocale(selectedLocale); + } + + while (_appRunning) + { + await _terminal.ClearAsync(); + await _terminal.WriteLineAsync("========================================"); + await _terminal.WriteLineAsync(" OPEN THE BOX"); + await _terminal.WriteLineAsync(" web"); + await _terminal.WriteLineAsync("========================================"); + await _terminal.WriteLineAsync(""); + await _terminal.WriteLineAsync(_loc.Get("game.subtitle")); + await _terminal.WriteLineAsync(""); + + existingSaves = await _saveManager.ListSlotsAsync(); + + var options = new List(); + var actions = new List(); + + if (existingSaves.Count > 0) + { + var recent = existingSaves[0]; + var savedAt = recent.SavedAt.ToLocalTime(); + options.Add($"{_loc.Get("menu.continue")} ({recent.Name} {savedAt:dd/MM/yyyy HH:mm})"); + actions.Add("continue"); + } + + options.Add(_loc.Get("menu.new_game")); + actions.Add("new_game"); + + if (existingSaves.Count > 1) + { + options.Add(_loc.Get("menu.load_game")); + actions.Add("load_game"); + } + + options.Add(_loc.Get("menu.language")); + actions.Add("language"); + + int choice = await _terminal.ShowSelectionAsync("", options, _renderContext.HasArrowSelection); + + switch (actions[choice]) + { + case "continue": + await ContinueGame(existingSaves[0].Name); + break; + case "new_game": + await NewGame(); + break; + case "load_game": + await LoadGame(); + break; + case "language": + await ChangeLanguageAsync(); + break; + } + } + } + + private async Task ContinueGame(string slotName) + { + var loaded = await _saveManager.LoadAsync(slotName); + if (loaded == null) + { + await ShowErrorAsync("Failed to load save."); + await WaitForKeyAsync(); + return; + } + + _state = loaded; + ChangeLocale(_state.CurrentLocale); + await InitializeGameAsync(); + + await ShowAdaptiveWelcome(); + await WaitForKeyAsync(); + + await GameLoop(); + } + + private async Task NewGame() + { + await _terminal.WriteAsync($"{_loc.Get("prompt.name")}: "); + string name = await _terminal.ReadLineAsync(); + if (string.IsNullOrWhiteSpace(name)) name = "BoxOpener"; + + _state = GameState.Create(name, _loc.CurrentLocale); + await InitializeGameAsync(); + + var starterBox = ItemInstance.Create("box_starter"); + _state.AddItem(starterBox); + + await ShowMessageAsync(""); + await ShowMessageAsync(_loc.Get("misc.welcome", name)); + await ShowMessageAsync(_loc.Get("box.starter.desc")); + await WaitForKeyAsync(); + + await GameLoop(); + } + + private async Task LoadGame() + { + var slots = await _saveManager.ListSlotsAsync(); + if (slots.Count == 0) + { + await ShowMessageAsync(_loc.Get("save.no_saves")); + await WaitForKeyAsync(); + return; + } + + var options = slots.Select(s => $"{s.Name} ({s.SavedAt:yyyy-MM-dd HH:mm})").ToList(); + options.Add(_loc.Get("menu.back")); + + int choice = await _terminal.ShowSelectionAsync(_loc.Get("save.choose_slot"), options, + _renderContext.HasArrowSelection); + if (choice >= slots.Count) return; + + var loaded = await _saveManager.LoadAsync(slots[choice].Name); + if (loaded == null) + { + await ShowErrorAsync("Failed to load save."); + await WaitForKeyAsync(); + return; + } + + _state = loaded; + ChangeLocale(_state.CurrentLocale); + await InitializeGameAsync(); + + await ShowAdaptiveWelcome(); + await WaitForKeyAsync(); + + await GameLoop(); + } + + private async Task ShowAdaptiveWelcome() + { + int boxes = _state.TotalBoxesOpened; + string name = _state.PlayerName; + string message = boxes switch + { + >= 500 => _loc.Get("misc.welcome_back_500", name, boxes.ToString()), + >= 200 => _loc.Get("misc.welcome_back_200", name, boxes.ToString()), + >= 50 => _loc.Get("misc.welcome_back_50", name), + _ => _loc.Get("misc.welcome_back", name) + }; + await ShowMessageAsync(message); + } + + private async Task ChangeLanguageAsync() + { + var options = new List { "English", "Francais" }; + int choice = await _terminal.ShowSelectionAsync(_loc.Get("menu.language"), options, + _renderContext.HasArrowSelection); + + var newLocale = choice == 0 ? Locale.EN : Locale.FR; + ChangeLocale(newLocale); + + if (_state != null) + _state.CurrentLocale = newLocale; + } + + // ── Game loop ──────────────────────────────────────────────────────── + + private async Task GameLoop() + { + _sessionStart = DateTime.UtcNow; + _gameRunning = true; + + while (_gameRunning) + { + _state.TotalPlayTime += DateTime.UtcNow - _sessionStart; + _sessionStart = DateTime.UtcNow; + + if (_state.HasUIFeature(UIFeature.AutoSave)) + await _saveManager.SaveAsync(_state, _state.PlayerName); + + TickCraftingJobs(); + + if (_renderContext.HasFullLayout) + await _terminal.ClearAsync(); + + UpdateCompletionPercent(); + await ShowGameStateAsync(); + + var actions = BuildActionList(); + if (actions.Count == 0) + { + await ShowMessageAsync(_loc.Get("error.no_boxes")); + await WaitForKeyAsync(); + break; + } + + int choice = await _terminal.ShowSelectionAsync( + _loc.Get("prompt.choose_action"), + actions.Select(a => a.label).ToList(), + _renderContext.HasArrowSelection); + + await ExecuteAction(actions[choice].action); + } + } + + private List<(string label, string action)> BuildActionList() + { + var actions = new List<(string label, string action)>(); + + var boxes = _state.Inventory.Where(i => _registry.IsBox(i.DefinitionId)).ToList(); + if (boxes.Count > 0) + actions.Add((_loc.Get("action.open_box") + $" ({boxes.Count})", "open_box")); + + if (_state.Inventory.Count > 0) + actions.Add((_loc.Get("action.inventory"), "inventory")); + + if (_state.UnlockedAdventures.Count > 0) + { + string adventureLabel = _loc.Get("action.adventure"); + if (_state.CompletedAdventures.Count == 0) + adventureLabel = $"({_loc.Get("action.new")}) {adventureLabel}"; + actions.Add((adventureLabel, "adventure")); + } + + if (_state.UnlockedCosmetics.Count > 0) + actions.Add((_loc.Get("action.appearance"), "appearance")); + + var completedJobs = _state.ActiveCraftingJobs.Where(j => j.IsComplete).ToList(); + if (completedJobs.Count > 0) + actions.Add((_loc.Get("action.collect_crafting") + $" ({completedJobs.Count})", "collect_crafting")); + + if (!_state.HasUIFeature(UIFeature.AutoSave)) + actions.Add((_loc.Get("action.save"), "save")); + + actions.Add((_loc.Get("action.quit"), "quit")); + + return actions; + } + + private async Task ExecuteAction(string action) + { + switch (action) + { + case "open_box": await OpenBoxAction(); break; + case "inventory": await ShowInventory(); break; + case "adventure": await StartAdventure(); break; + case "appearance": await ChangeAppearance(); break; + case "collect_crafting": await CollectCrafting(); break; + case "save": await SaveGame(); break; + case "quit": _gameRunning = false; break; + } + } + + // ── Box opening ────────────────────────────────────────────────────── + + private async Task OpenBoxAction() + { + var boxes = _state.Inventory.Where(i => _registry.IsBox(i.DefinitionId)).ToList(); + if (boxes.Count == 0) + { + await ShowMessageAsync(_loc.Get("box.no_boxes")); + return; + } + + var grouped = boxes.GroupBy(b => b.DefinitionId).ToList(); + var boxNames = grouped.Select(g => + { + var name = GetLocalizedName(g.Key); + return g.Count() > 1 ? $"{name} (x{g.Count()})" : name; + }).ToList(); + boxNames.Add(_loc.Get("menu.back")); + + int choice = await _terminal.ShowSelectionAsync(_loc.Get("prompt.choose_box"), boxNames, + _renderContext.HasArrowSelection); + if (choice >= grouped.Count) return; + + var boxInstance = grouped[choice].First(); + + var openAction = new OpenBoxAction(boxInstance.Id) + { + BoxDefinitionId = boxInstance.DefinitionId + }; + var events = _simulation.ProcessAction(openAction, _state); + + await RenderEvents(events); + } + + // ── Render events ──────────────────────────────────────────────────── + + private async Task RenderEvents(List events) + { + var autoConsumedIds = events.OfType().Select(e => e.InstanceId).ToHashSet(); + var allLoot = new List<(string name, string rarity, string category)>(); + var deferredMessages = new List(); + var consumedItemNames = new Dictionary(); + bool primaryBoxShown = false; + + foreach (var evt in events) + { + switch (evt) + { + case BoxOpenedEvent boxEvt: + var boxDef = _registry.GetBox(boxEvt.BoxId); + var boxName = _loc.Get(boxDef?.NameKey ?? boxEvt.BoxId); + if (!boxEvt.IsAutoOpen && !primaryBoxShown) + { + await ShowBoxOpening(boxName, boxDef?.Rarity.ToString() ?? "Common"); + primaryBoxShown = true; + } + break; + + case ItemReceivedEvent itemEvt: + if (autoConsumedIds.Contains(itemEvt.Item.Id)) + break; + var itemDef = _registry.GetItem(itemEvt.Item.DefinitionId); + var itemBoxDef = itemDef is null ? _registry.GetBox(itemEvt.Item.DefinitionId) : null; + allLoot.Add(( + GetLocalizedName(itemEvt.Item.DefinitionId), + (itemDef?.Rarity ?? itemBoxDef?.Rarity ?? ItemRarity.Common).ToString(), + (itemDef?.Category ?? ItemCategory.Box).ToString() + )); + break; + + case UIFeatureUnlockedEvent uiEvt: + _renderContext.Unlock(uiEvt.Feature); + var featureLabel = _loc.Get(GetUIFeatureLocKey(uiEvt.Feature)); + await ShowUIFeatureUnlocked(featureLabel); + await WaitForKeyAsync(); + break; + + case ItemConsumedEvent consumedEvt: + consumedItemNames[consumedEvt.InstanceId] = GetLocalizedName( + _state.Inventory.FirstOrDefault(i => i.Id == consumedEvt.InstanceId)?.DefinitionId + ?? events.OfType() + .FirstOrDefault(r => r.Item.Id == consumedEvt.InstanceId)?.Item.DefinitionId + ?? "?"); + break; + + case InteractionTriggeredEvent interEvt: + string interMsg; + if (interEvt.TriggerItemId is not null && interEvt.PartnerItemId is not null) + { + var triggerName = GetLocalizedName(interEvt.TriggerItemId); + var partnerName = GetLocalizedName(interEvt.PartnerItemId); + interMsg = $"{triggerName} + {partnerName}: {_loc.Get(interEvt.DescriptionKey)}"; + } + else + { + interMsg = _loc.Get(interEvt.DescriptionKey); + } + deferredMessages.Add(interMsg); + break; + + case AdventureStartedEvent advEvt: + await ShowMessageAsync(_loc.Get("adventure.new_unlocked", GetAdventureName(advEvt.Theme))); + break; + + case MusicPlayedEvent: + await ShowMessageAsync(_loc.Get("box.music.desc")); + break; + + case CookieFortuneEvent cookieEvt: + await ShowMessageAsync("--- Fortune Cookie ---"); + await ShowMessageAsync(_loc.Get(cookieEvt.MessageKey)); + await ShowMessageAsync("----------------------"); + break; + + case CraftingStartedEvent craftEvt: + var recipeName = _registry.Recipes.TryGetValue(craftEvt.RecipeId, out var recDef) + ? _loc.Get(recDef.NameKey) : craftEvt.RecipeId; + await ShowMessageAsync(_loc.Get("craft.started", recipeName, _loc.Get($"workstation.{craftEvt.Workstation}"))); + break; + + case CraftingCompletedEvent craftDoneEvt: + await ShowMessageAsync(_loc.Get("craft.completed", craftDoneEvt.Workstation.ToString())); + break; + + case CraftingCollectedEvent: + break; + } + } + + if (allLoot.Count > 0) + await ShowLootReveal(allLoot); + + foreach (var msg in deferredMessages) + { + await ShowMessageAsync(""); + await ShowInteraction(msg); + } + + await WaitForKeyAsync(); + } + + // ── Inventory ──────────────────────────────────────────────────────── + + private async Task ShowInventory() + { + if (_state.Inventory.Count == 0) + { + await ShowMessageAsync(_loc.Get("inventory.empty")); + await WaitForKeyAsync(); + return; + } + + if (!_renderContext.HasInventoryPanel) + { + await ShowRawInventory(); + return; + } + + var grouped = InventoryPanel.GetGroupedItems(_state, _registry); + int totalItems = grouped.Count; + int maxVisible = InventoryPanel.MaxVisibleRows; + int scrollOffset = 0; + int selectedIndex = 0; + int previousRenderedLines = 0; + + await _terminal.ClearAsync(); + + while (true) + { + grouped = InventoryPanel.GetGroupedItems(_state, _registry); + totalItems = grouped.Count; + if (totalItems == 0) return; + int maxOffset = Math.Max(0, totalItems - maxVisible); + + selectedIndex = Math.Clamp(selectedIndex, 0, totalItems - 1); + scrollOffset = Math.Clamp(scrollOffset, 0, maxOffset); + + if (selectedIndex < scrollOffset) + scrollOffset = selectedIndex; + else if (selectedIndex >= scrollOffset + maxVisible) + scrollOffset = selectedIndex - maxVisible + 1; + + // Render to buffer using Spectre off-screen + var writer = new StringWriter(); + var bufferConsole = AnsiConsole.Create(new AnsiConsoleSettings + { + Out = new AnsiConsoleOutput(writer), + Ansi = AnsiSupport.Yes, + ColorSystem = ColorSystemSupport.TrueColor + }); + bufferConsole.Profile.Width = WebTerminal.Width; + + bufferConsole.Write(InventoryPanel.Render(_state, _registry, _loc, scrollOffset, selectedIndex: selectedIndex)); + + var selectedGroup = grouped[selectedIndex]; + var detailPanel = InventoryPanel.RenderDetailPanel(selectedGroup, _registry, _loc, _state); + if (detailPanel is not null) + bufferConsole.Write(detailPanel); + + if (_renderContext.HasArrowSelection) + { + bool isUsable = selectedGroup.Category is ItemCategory.Consumable + && selectedGroup.Def?.ResourceType is not null; + bool isLore = selectedGroup.Category is ItemCategory.LoreFragment; + string controls = isUsable + ? _loc.Get("inventory.controls_use") + : isLore + ? _loc.Get("inventory.controls_lore") + : _loc.Get("inventory.controls"); + bufferConsole.MarkupLine($"[dim]{Markup.Escape(controls)}[/]"); + } + + string rendered = writer.ToString().Replace("\n", "\r\n"); + int renderedLines = rendered.Split('\n').Length; + + // Clear previous render and write new one + if (previousRenderedLines > 0) + await _terminal.WriteAsync($"\x1b[{previousRenderedLines}A\x1b[J"); + await _terminal.WriteAsync(rendered); + previousRenderedLines = renderedLines; + + var key = await _terminal.ReadKeyAsync(); + switch (key.Key) + { + case ConsoleKey.UpArrow: + selectedIndex = Math.Max(0, selectedIndex - 1); + break; + case ConsoleKey.DownArrow: + selectedIndex = Math.Min(totalItems - 1, selectedIndex + 1); + break; + case ConsoleKey.PageUp: + selectedIndex = Math.Max(0, selectedIndex - maxVisible); + break; + case ConsoleKey.PageDown: + selectedIndex = Math.Min(totalItems - 1, selectedIndex + maxVisible); + break; + case ConsoleKey.Enter: + await HandleInventoryAction(selectedGroup); + break; + case ConsoleKey.Escape: + case ConsoleKey.Q: + return; + } + } + } + + private async Task ShowRawInventory() + { + await ShowMessageAsync($"--- {_loc.Get("ui.inventory")} ---"); + var groups = _state.Inventory.GroupBy(i => i.DefinitionId).ToList(); + foreach (var g in groups) + { + string name = GetLocalizedName(g.Key); + int qty = g.Sum(i => i.Quantity); + await ShowMessageAsync(qty > 1 ? $" {name} (x{qty})" : $" {name}"); + } + await ShowMessageAsync(""); + await WaitForKeyAsync(); + } + + private async Task HandleInventoryAction(InventoryGroup item) + { + if (item.Def is null) return; + + switch (item.Category) + { + case ItemCategory.Consumable when item.Def.ResourceType.HasValue: + var events = _simulation.ProcessAction( + new UseItemAction(item.FirstInstance.Id), _state); + foreach (var evt in events) + { + switch (evt) + { + case ResourceChangedEvent resEvt: + var resName = _loc.Get($"resource.{resEvt.Type.ToString().ToLower()}"); + string itemName = _loc.Get(item.Def.NameKey); + int remaining = _state.Inventory + .Where(i => i.DefinitionId == item.DefId).Sum(i => i.Quantity); + string usedMsg = remaining > 0 + ? _loc.Get("inventory.item_used_qty", itemName, remaining.ToString()) + : _loc.Get("inventory.item_used", itemName); + await ShowMessageAsync(usedMsg); + await ShowMessageAsync($"{resName}: {resEvt.OldValue} {UnicodeSupport.Arrow} {resEvt.NewValue}"); + break; + case MessageEvent msgEvt: + await ShowMessageAsync(_loc.Get(msgEvt.MessageKey, msgEvt.Args ?? [])); + break; + } + } + break; + + case ItemCategory.Cookie: + var cookieEvents = _simulation.ProcessAction( + new UseItemAction(item.FirstInstance.Id), _state); + foreach (var evt in cookieEvents) + { + switch (evt) + { + case CookieFortuneEvent cookieEvt: + await ShowMessageAsync("--- Fortune Cookie ---"); + await ShowMessageAsync(_loc.Get(cookieEvt.MessageKey)); + await ShowMessageAsync("----------------------"); + break; + case ResourceChangedEvent resEvt: + var cookieResName = _loc.Get($"resource.{resEvt.Type.ToString().ToLower()}"); + await ShowMessageAsync($"{cookieResName}: {resEvt.OldValue} {UnicodeSupport.Arrow} {resEvt.NewValue}"); + break; + case MessageEvent msgEvt: + await ShowMessageAsync(_loc.Get(msgEvt.MessageKey, msgEvt.Args ?? [])); + break; + } + } + break; + + case ItemCategory.LoreFragment: + await ShowLoreFragment(item); + break; + } + } + + private async Task ShowLoreFragment(InventoryGroup item) + { + await _terminal.ClearAsync(); + string name = _loc.Get(item.Def!.NameKey); + string loreKey = $"lore.fragment_{item.DefId.Replace("lore_", "")}"; + string loreText = _loc.Get(loreKey); + + var panel = new Panel($"[italic]{Markup.Escape(loreText)}[/]") + .Header($"[bold yellow]{Markup.Escape(name)}[/]") + .Border(BoxBorder.Double) + .BorderStyle(new Style(Color.Yellow)) + .Padding(2, 1) + .Expand(); + + await _terminal.WriteRenderableAsync(panel); + await WaitForKeyAsync(); + } + + // ── Adventures ─────────────────────────────────────────────────────── + + private async Task StartAdventure() + { + var available = _state.UnlockedAdventures.ToList(); + if (available.Count == 0) + { + await ShowMessageAsync(_loc.Get("adventure.none_available")); + await WaitForKeyAsync(); + return; + } + + var options = available.Select(a => + { + bool completed = _state.CompletedAdventures.Contains(a.ToString()); + string prefix = completed ? $"[{_loc.Get("adventure.done")}] " : ""; + return prefix + GetAdventureName(a); + }).ToList(); + options.Add(_loc.Get("menu.back")); + + int choice = await _terminal.ShowSelectionAsync(_loc.Get("action.adventure"), options, + _renderContext.HasArrowSelection); + if (choice >= available.Count) return; + + await RunAdventure(available[choice]); + } + + private async Task RunAdventure(AdventureTheme theme) + { + try + { + string themeName = theme.ToString().ToLowerInvariant(); + + if (!_adventureScripts.TryGetValue(themeName, out string? scriptContent)) + { + await ShowMessageAsync(_loc.Get("adventure.coming_soon", GetAdventureName(theme))); + await WaitForKeyAsync(); + return; + } + + string? translationContent = null; + if (_loc.CurrentLocale != Locale.EN) + { + _adventureTranslations.TryGetValue(themeName, out translationContent); + } + + // Create a web-compatible renderer adapter for AdventureEngine + var rendererAdapter = new WebRendererAdapter(this); + var adventureEngine = new AdventureEngine(rendererAdapter, _loc); + var events = await adventureEngine.PlayAdventureFromContent( + theme, _state, scriptContent, translationContent); + + foreach (var evt in events) + { + if (evt.Kind == GameEventKind.ItemGranted) + _state.AddItem(ItemInstance.Create(evt.TargetId, evt.Amount)); + } + + await ShowMessageAsync(_loc.Get("adventure.completed")); + + if (theme == AdventureTheme.Destiny) + { + await WaitForKeyAsync(); + await ShowMessageAsync(_loc.Get("destiny.epilogue")); + var endOptions = new List + { + _loc.Get("destiny.continue"), + _loc.Get("destiny.quit") + }; + int endChoice = await _terminal.ShowSelectionAsync("", endOptions, + _renderContext.HasArrowSelection); + if (endChoice == 1) + { + await ShowMessageAsync(_loc.Get("destiny.thanks")); + await WaitForKeyAsync(); + _gameRunning = false; + return; + } + } + } + catch (Exception ex) + { + await ShowErrorAsync($"Adventure error: {ex.Message}"); + } + await WaitForKeyAsync(); + } + + // ── Appearance ─────────────────────────────────────────────────────── + + private async Task ChangeAppearance() + { + var cosmeticItems = _state.Inventory + .Where(i => + { + var def = _registry.GetItem(i.DefinitionId); + return def?.Category == ItemCategory.Cosmetic && def.CosmeticSlot.HasValue; + }) + .GroupBy(i => i.DefinitionId) + .Select(g => g.First()) + .ToList(); + + if (cosmeticItems.Count == 0) + { + await ShowMessageAsync(_loc.Get("cosmetic.no_cosmetics")); + await WaitForKeyAsync(); + return; + } + + var options = cosmeticItems.Select(i => + { + var def = _registry.GetItem(i.DefinitionId); + var slotKey = $"cosmetic.slot.{def?.CosmeticSlot?.ToString().ToLower()}"; + var slotName = _loc.Get(slotKey); + return $"[{slotName}] {GetLocalizedName(i.DefinitionId)}"; + }).ToList(); + options.Add(_loc.Get("menu.back")); + + int choice = await _terminal.ShowSelectionAsync(_loc.Get("action.appearance"), options, + _renderContext.HasArrowSelection); + if (choice >= cosmeticItems.Count) return; + + var action = new EquipCosmeticAction(cosmeticItems[choice].Id); + var events = _simulation.ProcessAction(action, _state); + foreach (var evt in events) + { + if (evt is CosmeticEquippedEvent cosEvt) + { + var slotKey = $"cosmetic.slot.{cosEvt.Slot.ToString().ToLower()}"; + var slotName = _loc.Get(slotKey); + var cosmeticDef = _registry.Items.Values.FirstOrDefault( + d => d.CosmeticSlot == cosEvt.Slot && + string.Equals(d.CosmeticValue, cosEvt.NewValue, StringComparison.OrdinalIgnoreCase)); + var valueName = cosmeticDef is not null + ? _loc.Get(cosmeticDef.NameKey) + : cosEvt.NewValue; + await ShowMessageAsync(_loc.Get("cosmetic.equipped", slotName, valueName)); + } + else if (evt is MessageEvent msgEvt) + await ShowMessageAsync(_loc.Get(msgEvt.MessageKey, msgEvt.Args ?? [])); + } + await WaitForKeyAsync(); + } + + // ── Crafting ───────────────────────────────────────────────────────── + + private void TickCraftingJobs() + { + _craftingEngine?.TickJobs(_state); + } + + private async Task CollectCrafting() + { + var events = _craftingEngine.CollectCompleted(_state, _registry); + + var newItems = events.OfType().Select(e => e.Item).ToList(); + var metaEngine = new MetaEngine(); + events.AddRange(metaEngine.ProcessNewItems(newItems, _state, _registry)); + + events.AddRange(_craftingEngine.AutoCraftCheck(_state, _registry)); + + await RenderEvents(events); + } + + private async Task SaveGame() + { + await ShowMessageAsync(_loc.Get("save.saving")); + await _saveManager.SaveAsync(_state, _state.PlayerName); + await ShowMessageAsync(_loc.Get("save.saved", _state.PlayerName)); + await WaitForKeyAsync(); + } + + // ── Rendering helpers ──────────────────────────────────────────────── + + private async Task ShowGameStateAsync() + { + if (_renderContext.HasFullLayout) + { + await RenderFullLayout(); + } + else + { + await RenderSequentialPanels(); + } + } + + private async Task RenderFullLayout() + { + var topRow = new Table().NoBorder().HideHeaders().Expand(); + topRow.AddColumn(new TableColumn("c1").Width(20).NoWrap()); + topRow.AddColumn(new TableColumn("c2").Width(30).NoWrap()); + topRow.AddColumn(new TableColumn("c3").NoWrap()); + + topRow.AddRow( + _renderContext.HasPortraitPanel + ? PortraitPanel.Render(_state.Appearance, useColors: _renderContext.HasColors) + : new Panel("[dim]?[/]").Header("Portrait").Expand(), + _renderContext.HasStatsPanel + ? StatsPanel.Render(_state, _loc) + : new Panel($"[dim italic]{Markup.Escape(_loc.Get("panel.locked.stats"))}[/]").Header("Stats").Expand(), + _renderContext.HasResourcePanel + ? ResourcePanel.Render(_state, _loc) + : new Panel($"[dim italic]{Markup.Escape(_loc.Get("panel.locked.resources"))}[/]") + .Header(Markup.Escape(_loc.Get("resource.title"))).Expand()); + + await _terminal.WriteRenderableAsync(topRow); + + var botRow = new Table().NoBorder().HideHeaders().Expand(); + botRow.AddColumn(new TableColumn("c1").Width(60).NoWrap()); + botRow.AddColumn(new TableColumn("c2").NoWrap()); + + IRenderable leftPanel = _renderContext.HasInventoryPanel + ? InventoryPanel.Render(_state, _registry, _loc, compact: true) + : new Panel($"[dim italic]{Markup.Escape(_loc.Get("panel.locked.inventory"))}[/]") + .Header("Inventory").Expand(); + + var rightItems = new List(); + if (_renderContext.HasCraftingPanel) + rightItems.Add(CraftingPanel.Render(_state, _registry, _loc)); + if (_renderContext.HasCompletionTracker) + { + rightItems.Add(new Markup($"[bold cyan]{Markup.Escape(_loc.Get("ui.completion", _renderContext.CompletionPercent))}[/]")); + foreach (var hint in _renderContext.NextHints) + rightItems.Add(new Markup($" [dim]{Markup.Escape(hint)}[/]")); + } + + IRenderable rightPanel = rightItems.Count > 0 + ? new Rows(rightItems) + : new Panel("[dim]???[/]").Header("???").Expand(); + + botRow.AddRow(leftPanel, rightPanel); + await _terminal.WriteRenderableAsync(botRow); + } + + private async Task RenderSequentialPanels() + { + var topPanels = new List(); + if (_renderContext.HasPortraitPanel) topPanels.Add(PortraitPanel.Render(_state.Appearance, useColors: _renderContext.HasColors)); + if (_renderContext.HasStatsPanel) topPanels.Add(StatsPanel.Render(_state, _loc)); + if (_renderContext.HasResourcePanel) topPanels.Add(ResourcePanel.Render(_state, _loc)); + + if (!_renderContext.HasStatsPanel) + { + string boxesLabel = _loc.Get("stats.boxes_opened"); + if (_renderContext.HasColors) + await _terminal.WriteMarkupLineAsync($"[dim]{Markup.Escape(boxesLabel)}: {_state.TotalBoxesOpened}[/]"); + else + await _terminal.WriteLineAsync($"{boxesLabel}: {_state.TotalBoxesOpened}"); + } + + if (topPanels.Count > 1) + { + var row = new Table().NoBorder().HideHeaders().Expand(); + foreach (var _ in topPanels) row.AddColumn(new TableColumn("").NoWrap()); + row.AddRow(topPanels.ToArray()); + await _terminal.WriteRenderableAsync(row); + } + else if (topPanels.Count == 1) + { + await _terminal.WriteRenderableAsync(topPanels[0]); + } + + if (_renderContext.HasInventoryPanel) + await _terminal.WriteRenderableAsync(InventoryPanel.Render(_state, _registry, _loc, compact: true)); + + if (_renderContext.HasCraftingPanel) + await _terminal.WriteRenderableAsync(CraftingPanel.Render(_state, _registry, _loc)); + + if (_renderContext.HasCompletionTracker) + { + await _terminal.WriteRenderableAsync( + new Rule($"[bold cyan]{Markup.Escape(_loc.Get("ui.completion", _renderContext.CompletionPercent))}[/]") + .RuleStyle("cyan")); + foreach (var hint in _renderContext.NextHints) + await _terminal.WriteMarkupLineAsync($" [dim]{Markup.Escape(hint)}[/]"); + } + } + + // ── Display methods (styled output) ────────────────────────────────── + + internal async Task ShowMessageAsync(string message) + { + if (_renderContext.HasColors) + await _terminal.WriteMarkupLineAsync($"[green]{Markup.Escape(message)}[/]"); + else + await _terminal.WriteLineAsync(message); + } + + private async Task ShowErrorAsync(string message) + { + if (_renderContext.HasColors) + await _terminal.WriteMarkupLineAsync($"[bold red]ERROR:[/] [red]{Markup.Escape(message)}[/]"); + else + await _terminal.WriteLineAsync($"ERROR: {message}"); + } + + private async Task ShowBoxOpening(string boxName, string rarity) + { + if (_renderContext.HasBoxAnimation) + { + string color = RarityColor(rarity); + await _terminal.WriteMarkupLineAsync( + $"[bold {color}]{Markup.Escape(_loc.Get("box.opening", boxName))}[/]"); + await Task.Delay(1500); + await _terminal.WriteMarkupLineAsync( + $"[bold {color}]{Markup.Escape(_loc.Get("box.shimmer"))}[/]"); + await Task.Delay(1000); + await _terminal.WriteMarkupLineAsync( + $"[bold {color}]{Markup.Escape(_loc.Get("box.opened_short", boxName))}[/]"); + } + else if (_renderContext.HasColors) + { + string color = RarityColor(rarity); + await _terminal.WriteMarkupLineAsync( + $"[bold {color}]{Markup.Escape(_loc.Get("box.opening", boxName))}[/]"); + await Task.Delay(800); + await _terminal.WriteMarkupLineAsync( + $"[bold {color}]{Markup.Escape(_loc.Get("box.opened_short", boxName))}[/]"); + } + else + { + await _terminal.WriteLineAsync(_loc.Get("box.opening", boxName)); + await Task.Delay(500); + await _terminal.WriteLineAsync(_loc.Get("box.opened_short", boxName)); + } + } + + private async Task ShowLootReveal(List<(string name, string rarity, string category)> items) + { + if (_renderContext.HasInventoryPanel) + { + var table = new Table() + .Border(TableBorder.Rounded) + .Title($"[bold yellow]{Markup.Escape(_loc.Get("loot.title"))}[/]") + .AddColumn(new TableColumn($"[bold]{Markup.Escape(_loc.Get("loot.name"))}[/]").Centered()) + .AddColumn(new TableColumn($"[bold]{Markup.Escape(_loc.Get("loot.rarity"))}[/]").Centered()); + + foreach (var (name, rarity, category) in items) + { + string color = RarityColor(rarity); + string localizedRarity = _loc.Get($"rarity.{rarity.ToLower()}"); + string stars = RarityStars(rarity); + string displayName = stars.Length > 0 ? $"{stars}{name}" : name; + table.AddRow( + $"[{color}]{Markup.Escape(displayName)}[/]", + $"[{color}]{Markup.Escape(localizedRarity)}[/]"); + } + + await _terminal.WriteRenderableAsync(table); + } + else if (_renderContext.HasColors) + { + await _terminal.WriteMarkupLineAsync($"[bold yellow]{Markup.Escape(_loc.Get("loot.received"))}[/]"); + foreach (var (name, rarity, category) in items) + { + string color = RarityColor(rarity); + string localizedRarity = _loc.Get($"rarity.{rarity.ToLower()}"); + await _terminal.WriteMarkupLineAsync( + $" - [{color}]{Markup.Escape(name)}[/] [{color}][[{Markup.Escape(localizedRarity)}]][/]"); + } + } + else + { + await _terminal.WriteLineAsync(_loc.Get("loot.received")); + foreach (var (name, rarity, category) in items) + { + string localizedRarity = _loc.Get($"rarity.{rarity.ToLower()}"); + await _terminal.WriteLineAsync($" - {name} [{localizedRarity}]"); + } + } + } + + private async Task ShowUIFeatureUnlocked(string featureName) + { + if (_renderContext.HasColors) + { + var star = UnicodeSupport.Star; + var panel = new Panel($"[bold yellow]{star} {Markup.Escape(featureName)} {star}[/]") + .Border(BoxBorder.Double) + .BorderStyle(new Style(Color.Yellow)) + .Padding(2, 0) + .Expand(); + await _terminal.WriteRenderableAsync(panel); + } + else + { + await _terminal.WriteLineAsync("========================================"); + await _terminal.WriteLineAsync($" {UnicodeSupport.Star} {featureName} {UnicodeSupport.Star}"); + await _terminal.WriteLineAsync("========================================"); + } + } + + private async Task ShowInteraction(string description) + { + if (_renderContext.HasColors) + await _terminal.WriteMarkupLineAsync($"[italic silver]* {Markup.Escape(description)} *[/]"); + else + await _terminal.WriteLineAsync($"* {description} *"); + } + + internal async Task ShowAdventureDialogueAsync(string? character, string text) + { + if (_renderContext.HasColors) + { + if (character is not null) + await _terminal.WriteMarkupLineAsync($"[bold aqua]{Markup.Escape(character)}[/]"); + await _terminal.WriteMarkupLineAsync($" [italic]{Markup.Escape(text)}[/]"); + await _terminal.WriteLineAsync(); + } + else + { + if (character is not null) + await _terminal.WriteLineAsync($"[{character}]"); + await _terminal.WriteLineAsync(text); + await _terminal.WriteLineAsync(); + } + } + + internal async Task ShowAdventureHintAsync(string hint) + { + if (_renderContext.HasColors) + await _terminal.WriteMarkupLineAsync($" [dim italic]{Markup.Escape(hint)}[/]"); + else + await _terminal.WriteLineAsync($" ({hint})"); + } + + internal async Task ShowAdventureChoiceAsync(List options) + { + if (_renderContext.HasArrowSelection) + { + return await _terminal.ShowSelectionAsync(_loc.Get("prompt.what_do"), options, true); + } + return await _terminal.ShowSelectionAsync(_loc.Get("prompt.what_do"), options, false); + } + + internal async Task WaitForKeyAsync(string? message = null) + { + string text = message ?? _loc.Get("prompt.press_key"); + if (_renderContext.HasColors) + await _terminal.WriteMarkupLineAsync($"[dim]{Markup.Escape(text)}[/]"); + else + await _terminal.WriteLineAsync(text); + + await _terminal.WaitForKeyAsync(); + await _terminal.WriteLineAsync(); + } + + // ── Helpers ────────────────────────────────────────────────────────── + + private void UpdateCompletionPercent() + { + if (!_renderContext.HasCompletionTracker) return; + + var totalCosmetics = _registry.Items.Values.Count(i => i.CosmeticSlot.HasValue); + var totalAdventures = Enum.GetValues().Length; + var totalUIFeatures = Enum.GetValues().Length; + var totalResources = Enum.GetValues().Length; + var totalStats = Enum.GetValues().Length; + var total = totalCosmetics + totalAdventures + totalUIFeatures + + totalResources + totalStats; + + var unlocked = _state.UnlockedCosmetics.Count + + _state.UnlockedAdventures.Count + + _state.UnlockedUIFeatures.Count + + _state.VisibleResources.Count + + _state.VisibleStats.Count; + + _renderContext.CompletionPercent = total > 0 ? (int)(unlocked * 100.0 / total) : 0; + + var hints = new List(); + var totalLore = _registry.Items.Values.Count(i => i.Category == ItemCategory.LoreFragment); + var ownedLore = _state.Inventory.Count(i => + _registry.GetItem(i.DefinitionId)?.Category == ItemCategory.LoreFragment); + if (ownedLore < totalLore) + hints.Add(_loc.Get("hint.lore", ownedLore, totalLore)); + + var completedAdv = _state.CompletedAdventures.Count; + var incompleteUnlocked = _state.UnlockedAdventures + .Count(a => !_state.CompletedAdventures.Contains(a.ToString())); + if (incompleteUnlocked > 0) + hints.Add(_loc.Get("hint.adventures", completedAdv, _state.UnlockedAdventures.Count)); + + if (!_state.UnlockedAdventures.Contains(AdventureTheme.Destiny)) + hints.Add(_loc.Get("hint.destiny")); + + _renderContext.NextHints = hints; + } + + private static string GetUIFeatureLocKey(UIFeature feature) => feature switch + { + UIFeature.TextColors => "meta.colors", + UIFeature.ExtendedColors => "meta.extended_colors", + UIFeature.ArrowKeySelection => "meta.arrows", + UIFeature.InventoryPanel => "meta.inventory", + UIFeature.ResourcePanel => "meta.resources", + UIFeature.StatsPanel => "meta.stats", + UIFeature.PortraitPanel => "meta.portrait", + UIFeature.FullLayout => "meta.layout", + UIFeature.KeyboardShortcuts => "meta.shortcuts", + UIFeature.BoxAnimation => "meta.animation", + UIFeature.CraftingPanel => "meta.crafting", + UIFeature.CompletionTracker => "meta.completion", + UIFeature.AutoSave => "meta.autosave", + _ => $"meta.{feature.ToString().ToLower()}" + }; + + private string GetAdventureName(AdventureTheme theme) + { + string key = $"adventure.name.{theme}"; + var name = _loc.Get(key); + return name.StartsWith("[MISSING:") ? theme.ToString() : name; + } + + private string GetLocalizedName(string definitionId) + { + var itemDef = _registry.GetItem(definitionId); + if (itemDef is not null) + return _loc.Get(itemDef.NameKey); + + var boxDef = _registry.GetBox(definitionId); + if (boxDef is not null) + return _loc.Get(boxDef.NameKey); + + return _loc.Get(definitionId); + } + + private static string RarityColor(string rarity) => rarity.ToLowerInvariant() switch + { + "common" => "white", + "uncommon" => "green", + "rare" => "blue", + "epic" => "purple", + "legendary" => "gold1", + "mythic" => "red", + _ => "white" + }; + + private static string RarityStars(string rarity) + { + var s = UnicodeSupport.Star; + return rarity.ToLowerInvariant() switch + { + "rare" => $"{s} ", + "epic" => $"{s}{s} ", + "legendary" => $"{s}{s}{s} ", + "mythic" => $"{s}{s}{s}{s} ", + _ => "" + }; + } +} + +/// +/// Adapter that implements IRenderer by delegating to WebGameHost's async methods. +/// Used by AdventureEngine which requires a synchronous IRenderer. +/// The adventure callbacks (HandleDialogue/HandleChoice) are called synchronously +/// by Loreline, so this adapter blocks on the async methods. +/// In WASM single-threaded mode, this works because Loreline callbacks +/// resume execution synchronously via TaskCompletionSource. +/// +internal sealed class WebRendererAdapter : IRenderer +{ + private readonly WebGameHost _host; + + public WebRendererAdapter(WebGameHost host) + { + _host = host; + } + + public void ShowMessage(string message) + { + _host.ShowMessageAsync(message).GetAwaiter().GetResult(); + } + + public void ShowError(string message) + { + _host.ShowMessageAsync($"ERROR: {message}").GetAwaiter().GetResult(); + } + + public void ShowBoxOpening(string boxName, string rarity) { } + public void ShowLootReveal(List<(string name, string rarity, string category)> items) { } + public void ShowGameState(GameState state, RenderContext context) { } + public void ShowUIFeatureUnlocked(string featureName) { } + + public void ShowAdventureDialogue(string? character, string text) + { + _host.ShowAdventureDialogueAsync(character, text).GetAwaiter().GetResult(); + } + + public int ShowAdventureChoice(List options) + { + return _host.ShowAdventureChoiceAsync(options).GetAwaiter().GetResult(); + } + + public void ShowAdventureHint(string hint) + { + _host.ShowAdventureHintAsync(hint).GetAwaiter().GetResult(); + } + + public void ShowInteraction(string description) { } + + public void WaitForKeyPress(string? message = null) + { + _host.WaitForKeyAsync(message).GetAwaiter().GetResult(); + } + + public void Clear() { } + + public int ShowSelection(string prompt, List options) + { + // Not used by AdventureEngine + return 0; + } + + public string ShowTextInput(string prompt) + { + // Not used by AdventureEngine + return ""; + } +} diff --git a/src/OpenTheBox.Web/WebSaveManager.cs b/src/OpenTheBox.Web/WebSaveManager.cs new file mode 100644 index 0000000..fdbc23c --- /dev/null +++ b/src/OpenTheBox.Web/WebSaveManager.cs @@ -0,0 +1,95 @@ +using System.Text.Json; +using Microsoft.JSInterop; +using OpenTheBox.Core; +using OpenTheBox.Persistence; + +namespace OpenTheBox.Web; + +/// +/// Save manager that uses browser localStorage for persistence. +/// Stores save data as JSON strings with the key prefix "otb_save_". +/// +public sealed class WebSaveManager +{ + private const string KeyPrefix = "otb_save_"; + private readonly IJSRuntime _js; + + public WebSaveManager(IJSRuntime js) + { + _js = js; + } + + public async Task SaveAsync(GameState state, string slotName = "autosave") + { + var data = new SaveData + { + SavedAt = DateTime.UtcNow, + State = state + }; + + string json = JsonSerializer.Serialize(data, SaveJsonContext.Default.SaveData); + await _js.InvokeVoidAsync("localStorage.setItem", KeyPrefix + slotName, json); + } + + public async Task LoadAsync(string slotName = "autosave") + { + string? json = await _js.InvokeAsync("localStorage.getItem", KeyPrefix + slotName); + + if (string.IsNullOrEmpty(json)) + return null; + + json = SaveMigrator.MigrateJson(json); + var data = JsonSerializer.Deserialize(json, SaveJsonContext.Default.SaveData); + return data?.State; + } + + public async Task> ListSlotsAsync() + { + var slots = new List<(string Name, DateTime SavedAt)>(); + + // Get all localStorage keys that start with our prefix + int length = await _js.InvokeAsync("eval", "localStorage.length"); + + for (int i = 0; i < length; i++) + { + string? key = await _js.InvokeAsync("localStorage.key", i); + if (key is null || !key.StartsWith(KeyPrefix)) + continue; + + string slotName = key[KeyPrefix.Length..]; + + try + { + string? json = await _js.InvokeAsync("localStorage.getItem", key); + if (json is not null) + { + var data = JsonSerializer.Deserialize(json, SaveJsonContext.Default.SaveData); + if (data is not null) + { + slots.Add((slotName, data.SavedAt)); + continue; + } + } + } + catch (JsonException) + { + // Corrupted save + } + + slots.Add((slotName, DateTime.MinValue)); + } + + return slots.OrderByDescending(s => s.SavedAt).ToList(); + } + + public async Task SlotExistsAsync(string slotName) + { + string? value = await _js.InvokeAsync("localStorage.getItem", KeyPrefix + slotName); + return value is not null; + } + + public async Task DeleteSlotAsync(string slotName) + { + await _js.InvokeVoidAsync("localStorage.removeItem", KeyPrefix + slotName); + } +} diff --git a/src/OpenTheBox.Web/WebTerminal.cs b/src/OpenTheBox.Web/WebTerminal.cs new file mode 100644 index 0000000..02c6fa0 --- /dev/null +++ b/src/OpenTheBox.Web/WebTerminal.cs @@ -0,0 +1,384 @@ +using System.Threading.Channels; +using Microsoft.JSInterop; +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace OpenTheBox.Web; + +/// +/// Bridge between the game loop (C#) and xterm.js (browser). +/// Handles output (ANSI strings → xterm.js) and input (xterm.js key events → C#). +/// +public sealed class WebTerminal +{ + private static WebTerminal? _instance; + + private readonly IJSRuntime _js; + private readonly Channel _keyChannel = + Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = true + }); + + public const int Width = 120; + public const int Height = 30; + + public WebTerminal(IJSRuntime js) + { + _js = js; + _instance = this; + } + + /// + /// Initializes the xterm.js terminal in the browser. + /// + public async Task InitAsync() + { + await _js.InvokeVoidAsync("terminalInterop.init"); + } + + // ── Output ─────────────────────────────────────────────────────────── + + /// + /// Writes raw text (including ANSI escape codes) to xterm.js. + /// + public async Task WriteAsync(string text) + { + await _js.InvokeVoidAsync("terminalInterop.write", text); + } + + /// + /// Writes a line of text followed by \r\n. + /// + public async Task WriteLineAsync(string text = "") + { + await WriteAsync(text + "\r\n"); + } + + /// + /// Clears the terminal screen. + /// + public async Task ClearAsync() + { + await _js.InvokeVoidAsync("terminalInterop.clear"); + } + + /// + /// Renders a Spectre.Console IRenderable to ANSI string using off-screen rendering. + /// + public static string RenderToAnsi(IRenderable renderable) + { + var writer = new StringWriter(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Out = new AnsiConsoleOutput(writer), + Ansi = AnsiSupport.Yes, + ColorSystem = ColorSystemSupport.TrueColor + }); + console.Profile.Width = Width; + console.Write(renderable); + return writer.ToString(); + } + + /// + /// Renders a Spectre.Console IRenderable and writes it to xterm.js. + /// + public async Task WriteRenderableAsync(IRenderable renderable) + { + string ansi = RenderToAnsi(renderable); + // Convert \n to \r\n for xterm.js + ansi = ansi.Replace("\n", "\r\n"); + await WriteAsync(ansi); + } + + /// + /// Writes Spectre.Console markup text to the terminal. + /// + public async Task WriteMarkupLineAsync(string markup) + { + var writer = new StringWriter(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Out = new AnsiConsoleOutput(writer), + Ansi = AnsiSupport.Yes, + ColorSystem = ColorSystemSupport.TrueColor + }); + console.Profile.Width = Width; + console.MarkupLine(markup); + string ansi = writer.ToString().Replace("\n", "\r\n"); + await WriteAsync(ansi); + } + + // ── Input ──────────────────────────────────────────────────────────── + + /// + /// Called from JavaScript when a key is pressed in xterm.js. + /// + [JSInvokable("OnTerminalInput")] + public static void OnTerminalInput(string data) + { + if (_instance is null) return; + + // Parse xterm.js input data into ConsoleKeyInfo events + var keys = ParseInput(data); + foreach (var key in keys) + { + _instance._keyChannel.Writer.TryWrite(key); + } + } + + /// + /// Waits for and returns the next key press from xterm.js. + /// + public async Task ReadKeyAsync() + { + return await _keyChannel.Reader.ReadAsync(); + } + + /// + /// Reads a full line of text input, echoing characters and handling backspace. + /// + public async Task ReadLineAsync() + { + var buffer = new List(); + + while (true) + { + var key = await ReadKeyAsync(); + + if (key.Key == ConsoleKey.Enter) + { + await WriteAsync("\r\n"); + return new string(buffer.ToArray()); + } + + if (key.Key == ConsoleKey.Backspace) + { + if (buffer.Count > 0) + { + buffer.RemoveAt(buffer.Count - 1); + await WriteAsync("\b \b"); + } + continue; + } + + if (key.KeyChar >= 32) // printable + { + buffer.Add(key.KeyChar); + await WriteAsync(key.KeyChar.ToString()); + } + } + } + + /// + /// Waits for any key press. + /// + public async Task WaitForKeyAsync() + { + await ReadKeyAsync(); + } + + // ── High-level input methods ───────────────────────────────────────── + + /// + /// Shows a numbered selection prompt and waits for the user to choose. + /// Supports arrow keys (when arrows are enabled) and number key shortcuts. + /// + public async Task ShowSelectionAsync(string prompt, List options, bool useArrows) + { + if (useArrows) + return await ShowArrowSelectionAsync(prompt, options); + + // Numbered selection + if (prompt.Length > 0) + await WriteLineAsync(prompt); + + for (int i = 0; i < options.Count; i++) + await WriteLineAsync($" {i + 1}. {options[i]}"); + + while (true) + { + await WriteAsync("> "); + string input = await ReadLineAsync(); + if (int.TryParse(input, out int choice) && choice >= 1 && choice <= options.Count) + return choice - 1; + + await WriteLineAsync($"Please enter a number between 1 and {options.Count}."); + } + } + + /// + /// Arrow-key selection with highlight and number key shortcuts. + /// Mirrors SpectreRenderer.ShowArrowSelection. + /// + private async Task ShowArrowSelectionAsync(string prompt, List options) + { + int selected = 0; + int pageSize = Math.Min(10, options.Count); + + while (true) + { + int scrollOffset = 0; + if (selected >= pageSize) + scrollOffset = selected - pageSize + 1; + int visibleEnd = Math.Min(scrollOffset + pageSize, options.Count); + + // Render using Spectre off-screen for consistent styling + var writer = new StringWriter(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Out = new AnsiConsoleOutput(writer), + Ansi = AnsiSupport.Yes, + ColorSystem = ColorSystemSupport.TrueColor + }); + console.Profile.Width = Width; + + if (prompt.Length > 0) + console.MarkupLine($"[bold]{Markup.Escape(prompt)}[/]"); + + for (int i = scrollOffset; i < visibleEnd; i++) + { + string num = $"{i + 1}."; + string text = Markup.Escape(options[i]); + if (i == selected) + console.MarkupLine($" [bold cyan]► {num,-3}[/] [bold]{text}[/]"); + else + console.MarkupLine($" [dim]{num,-3}[/] {text}"); + } + + if (scrollOffset > 0) + console.MarkupLine("[dim] ▲ ...[/]"); + if (visibleEnd < options.Count) + console.MarkupLine("[dim] ▼ ...[/]"); + + string rendered = writer.ToString().Replace("\n", "\r\n"); + int lineCount = rendered.Split('\n').Length; + + await WriteAsync(rendered); + + var key = await ReadKeyAsync(); + + switch (key.Key) + { + case ConsoleKey.UpArrow: + selected = (selected - 1 + options.Count) % options.Count; + break; + case ConsoleKey.DownArrow: + selected = (selected + 1) % options.Count; + break; + case ConsoleKey.Enter: + // Clear the rendered selection before returning + await WriteAsync($"\x1b[{lineCount}A\x1b[J"); + return selected; + case ConsoleKey.D1 or ConsoleKey.NumPad1: if (options.Count >= 1) { await WriteAsync($"\x1b[{lineCount}A\x1b[J"); return 0; } break; + case ConsoleKey.D2 or ConsoleKey.NumPad2: if (options.Count >= 2) { await WriteAsync($"\x1b[{lineCount}A\x1b[J"); return 1; } break; + case ConsoleKey.D3 or ConsoleKey.NumPad3: if (options.Count >= 3) { await WriteAsync($"\x1b[{lineCount}A\x1b[J"); return 2; } break; + case ConsoleKey.D4 or ConsoleKey.NumPad4: if (options.Count >= 4) { await WriteAsync($"\x1b[{lineCount}A\x1b[J"); return 3; } break; + case ConsoleKey.D5 or ConsoleKey.NumPad5: if (options.Count >= 5) { await WriteAsync($"\x1b[{lineCount}A\x1b[J"); return 4; } break; + case ConsoleKey.D6 or ConsoleKey.NumPad6: if (options.Count >= 6) { await WriteAsync($"\x1b[{lineCount}A\x1b[J"); return 5; } break; + case ConsoleKey.D7 or ConsoleKey.NumPad7: if (options.Count >= 7) { await WriteAsync($"\x1b[{lineCount}A\x1b[J"); return 6; } break; + case ConsoleKey.D8 or ConsoleKey.NumPad8: if (options.Count >= 8) { await WriteAsync($"\x1b[{lineCount}A\x1b[J"); return 7; } break; + case ConsoleKey.D9 or ConsoleKey.NumPad9: if (options.Count >= 9) { await WriteAsync($"\x1b[{lineCount}A\x1b[J"); return 8; } break; + } + + // Clear and re-render + await WriteAsync($"\x1b[{lineCount}A\x1b[J"); + } + } + + // ── Key parsing ────────────────────────────────────────────────────── + + /// + /// Parses xterm.js onData strings into ConsoleKeyInfo values. + /// xterm.js sends raw character sequences: single chars, ANSI escape sequences, etc. + /// + private static List ParseInput(string data) + { + var keys = new List(); + int i = 0; + + while (i < data.Length) + { + char c = data[i]; + + // ANSI escape sequence + if (c == '\x1b' && i + 1 < data.Length && data[i + 1] == '[') + { + // CSI sequence + i += 2; + string seq = ""; + while (i < data.Length && data[i] is >= '0' and <= '9' or ';') + { + seq += data[i]; + i++; + } + + if (i < data.Length) + { + char final = data[i]; + i++; + + var key = (seq, final) switch + { + ("", 'A') => MakeKey(ConsoleKey.UpArrow), + ("", 'B') => MakeKey(ConsoleKey.DownArrow), + ("", 'C') => MakeKey(ConsoleKey.RightArrow), + ("", 'D') => MakeKey(ConsoleKey.LeftArrow), + ("5", '~') => MakeKey(ConsoleKey.PageUp), + ("6", '~') => MakeKey(ConsoleKey.PageDown), + ("3", '~') => MakeKey(ConsoleKey.Delete), + _ => (ConsoleKeyInfo?)null + }; + + if (key.HasValue) + keys.Add(key.Value); + } + } + else if (c == '\x1b') + { + // Bare escape + keys.Add(MakeKey(ConsoleKey.Escape)); + i++; + } + else if (c == '\r') + { + keys.Add(MakeKey(ConsoleKey.Enter, '\r')); + i++; + } + else if (c == '\x7f' || c == '\b') + { + keys.Add(MakeKey(ConsoleKey.Backspace, '\b')); + i++; + } + else if (c == '\t') + { + keys.Add(MakeKey(ConsoleKey.Tab, '\t')); + i++; + } + else + { + // Regular character + var consoleKey = c switch + { + >= '0' and <= '9' => ConsoleKey.D0 + (c - '0'), + >= 'a' and <= 'z' => ConsoleKey.A + (c - 'a'), + >= 'A' and <= 'Z' => ConsoleKey.A + (c - 'A'), + ' ' => ConsoleKey.Spacebar, + _ => ConsoleKey.NoName + }; + + bool shift = c is >= 'A' and <= 'Z'; + keys.Add(new ConsoleKeyInfo(c, consoleKey, shift, false, false)); + i++; + } + } + + return keys; + } + + private static ConsoleKeyInfo MakeKey(ConsoleKey key, char keyChar = '\0') + { + return new ConsoleKeyInfo(keyChar, key, false, false, false); + } +} diff --git a/src/OpenTheBox.Web/wwwroot/css/terminal.css b/src/OpenTheBox.Web/wwwroot/css/terminal.css new file mode 100644 index 0000000..927c4c4 --- /dev/null +++ b/src/OpenTheBox.Web/wwwroot/css/terminal.css @@ -0,0 +1,90 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + background: #1a1a2e; + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + width: 100vw; + overflow: hidden; + font-family: 'Cascadia Mono', 'Consolas', 'Courier New', monospace; +} + +#terminal-container { + display: none; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + padding: 8px; +} + +#terminal-container.active { + display: flex; +} + +#terminal { + width: 100%; + height: 100%; + max-width: 960px; + max-height: 600px; +} + +/* Loading screen */ +#loading { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + color: #e0e0e0; +} + +#loading.hidden { + display: none; +} + +.loading-content { + text-align: center; +} + +.loading-content h1 { + font-size: 2rem; + margin-bottom: 1rem; + color: #ffd700; +} + +.loading-content p { + font-size: 1rem; + margin-bottom: 1.5rem; + color: #aaa; +} + +.spinner { + width: 40px; + height: 40px; + border: 4px solid #333; + border-top: 4px solid #ffd700; + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 0 auto; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Hide scrollbars in xterm */ +.xterm-viewport::-webkit-scrollbar { + display: none; +} + +.xterm-viewport { + scrollbar-width: none; +} diff --git a/src/OpenTheBox.Web/wwwroot/index.html b/src/OpenTheBox.Web/wwwroot/index.html new file mode 100644 index 0000000..1dafac2 --- /dev/null +++ b/src/OpenTheBox.Web/wwwroot/index.html @@ -0,0 +1,30 @@ + + + + + + Open The Box + + + + +
+
+

Open The Box

+

Loading game...

+
+
+
+
+
+
+ + + + + + + + + + diff --git a/src/OpenTheBox.Web/wwwroot/js/terminal-interop.js b/src/OpenTheBox.Web/wwwroot/js/terminal-interop.js new file mode 100644 index 0000000..3057929 --- /dev/null +++ b/src/OpenTheBox.Web/wwwroot/js/terminal-interop.js @@ -0,0 +1,68 @@ +// Terminal interop bridge: xterm.js <-> .NET Blazor WASM +window.terminalInterop = { + term: null, + fitAddon: null, + + init: function () { + const term = new Terminal({ + cols: 120, + rows: 30, + fontFamily: "'Cascadia Mono', 'Consolas', 'Courier New', monospace", + fontSize: 14, + theme: { + background: '#1a1a2e', + foreground: '#e0e0e0', + cursor: '#ffd700', + cursorAccent: '#1a1a2e', + selectionBackground: 'rgba(255, 215, 0, 0.3)' + }, + cursorBlink: true, + allowProposedApi: true, + scrollback: 1000 + }); + + const fitAddon = new FitAddon.FitAddon(); + term.loadAddon(fitAddon); + + term.open(document.getElementById('terminal')); + fitAddon.fit(); + + // Forward key input to C# + term.onData(function (data) { + DotNet.invokeMethodAsync('OpenTheBox.Web', 'OnTerminalInput', data); + }); + + // Handle resize + window.addEventListener('resize', function () { + fitAddon.fit(); + }); + + this.term = term; + this.fitAddon = fitAddon; + + // Show terminal, hide loading + document.getElementById('loading').classList.add('hidden'); + document.getElementById('terminal-container').classList.add('active'); + + term.focus(); + }, + + write: function (text) { + if (this.term) { + this.term.write(text); + } + }, + + clear: function () { + if (this.term) { + this.term.clear(); + this.term.write('\x1b[H\x1b[2J'); + } + }, + + focus: function () { + if (this.term) { + this.term.focus(); + } + } +}; diff --git a/src/OpenTheBox/Adventures/AdventureEngine.cs b/src/OpenTheBox/Adventures/AdventureEngine.cs index 2107059..051a628 100644 --- a/src/OpenTheBox/Adventures/AdventureEngine.cs +++ b/src/OpenTheBox/Adventures/AdventureEngine.cs @@ -177,6 +177,86 @@ public sealed class AdventureEngine return events; } + /// + /// Plays an adventure using pre-loaded script content instead of reading from files. + /// Used by the web (WASM) build where file system access is not available. + /// + /// + /// Dictionary mapping import file names to their content, for resolving Loreline imports. + /// + public async Task> PlayAdventureFromContent( + AdventureTheme theme, GameState state, + string scriptContent, string? translationContent = null, + Dictionary? importContents = null) + { + var events = new List(); + string themeName = theme.ToString().ToLowerInvariant(); + string adventureId = $"{themeName}/intro"; + + Script script = Engine.Parse( + scriptContent, + $"{themeName}/intro.lor", + (path, callback) => + { + if (importContents is not null && importContents.TryGetValue(path, out string? content)) + callback(content); + else + callback(string.Empty); + }); + + if (script is null) + { + _renderer.ShowError("Failed to parse adventure script."); + return []; + } + + var options = Interpreter.InterpreterOptions.Default(); + options.Functions = BuildCustomFunctions(state, events); + + if (translationContent is not null) + { + Script translationScript = Engine.Parse(translationContent); + if (translationScript is not null) + { + options.Translations = Engine.ExtractTranslations(translationScript); + } + } + + var tcs = new TaskCompletionSource(); + + bool hasSave = state.AdventureSaveData.TryGetValue(adventureId, out string? saveData) + && !string.IsNullOrEmpty(saveData); + + Loreline.Interpreter interpreter; + + if (hasSave) + { + interpreter = Engine.Resume( + script, + dialogue => HandleDialogue(dialogue), + choice => HandleChoice(choice), + finish => HandleFinish(finish, tcs), + saveData!, + options: options); + } + else + { + interpreter = Engine.Play( + script, + dialogue => HandleDialogue(dialogue), + choice => HandleChoice(choice), + finish => HandleFinish(finish, tcs), + options: options); + } + + await tcs.Task; + + state.AdventureSaveData.Remove(adventureId); + state.CompletedAdventures.Add(theme.ToString()); + + return events; + } + /// /// Saves the progress of the currently running adventure into the game state. /// diff --git a/src/OpenTheBox/Data/ContentRegistry.cs b/src/OpenTheBox/Data/ContentRegistry.cs index 19618c9..1e8e78e 100644 --- a/src/OpenTheBox/Data/ContentRegistry.cs +++ b/src/OpenTheBox/Data/ContentRegistry.cs @@ -90,4 +90,47 @@ public class ContentRegistry return registry; } + + /// + /// Loads content definitions from JSON strings and returns a populated registry. + /// Used by the web (WASM) build where file system access is not available. + /// + public static ContentRegistry LoadFromStrings( + string itemsJson, string boxesJson, string interactionsJson, string? recipesJson = null) + { + var registry = new ContentRegistry(); + + var items = JsonSerializer.Deserialize(itemsJson, ContentJsonContext.Default.ListItemDefinition); + if (items is not null) + { + foreach (var item in items) + registry.RegisterItem(item); + } + + var boxes = JsonSerializer.Deserialize(boxesJson, ContentJsonContext.Default.ListBoxDefinition); + if (boxes is not null) + { + foreach (var box in boxes) + registry.RegisterBox(box); + } + + var rules = JsonSerializer.Deserialize(interactionsJson, ContentJsonContext.Default.ListInteractionRule); + if (rules is not null) + { + foreach (var rule in rules) + registry.RegisterInteractionRule(rule); + } + + if (recipesJson is not null) + { + var recipes = JsonSerializer.Deserialize(recipesJson, ContentJsonContext.Default.ListRecipe); + if (recipes is not null) + { + foreach (var recipe in recipes) + registry.RegisterRecipe(recipe); + } + } + + return registry; + } } diff --git a/src/OpenTheBox/Localization/LocalizationManager.cs b/src/OpenTheBox/Localization/LocalizationManager.cs index d86208c..1a98418 100644 --- a/src/OpenTheBox/Localization/LocalizationManager.cs +++ b/src/OpenTheBox/Localization/LocalizationManager.cs @@ -77,4 +77,20 @@ public sealed class LocalizationManager { Load(locale); } + + /// + /// Loads the string table for the given locale from a JSON string. + /// Used by the web (WASM) build where file system access is not available. + /// + public void LoadFromString(Locale locale, string json) + { + CurrentLocale = locale; + _strings = []; + + var parsed = JsonSerializer.Deserialize(json, LocalizationJsonContext.Default.DictionaryStringString); + if (parsed is not null) + { + _strings = parsed; + } + } } diff --git a/src/OpenTheBox/Persistence/SaveJsonContext.cs b/src/OpenTheBox/Persistence/SaveJsonContext.cs index 87287a1..d8381a7 100644 --- a/src/OpenTheBox/Persistence/SaveJsonContext.cs +++ b/src/OpenTheBox/Persistence/SaveJsonContext.cs @@ -11,4 +11,4 @@ namespace OpenTheBox.Persistence; WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, UseStringEnumConverter = true)] -internal partial class SaveJsonContext : JsonSerializerContext; +public partial class SaveJsonContext : JsonSerializerContext; diff --git a/src/OpenTheBox/Rendering/UnicodeSupport.cs b/src/OpenTheBox/Rendering/UnicodeSupport.cs index c856e81..7375c4e 100644 --- a/src/OpenTheBox/Rendering/UnicodeSupport.cs +++ b/src/OpenTheBox/Rendering/UnicodeSupport.cs @@ -26,6 +26,13 @@ public static class UnicodeSupport /// public static void Initialize() { + // Browser (WASM) always supports UTF-8 via xterm.js + if (OperatingSystem.IsBrowser()) + { + IsUtf8 = true; + return; + } + // Windows Terminal always sets WT_SESSION if (Environment.GetEnvironmentVariable("WT_SESSION") is not null) {