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.LoadFromString(locale, "{}"); // no file I/O in WASM — use empty strings as fallback } // ── 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.Profile.Height = WebTerminal.Height; 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.Count(c => c == '\n'); // Overwrite in place: move cursor up, write new content over old lines, // then clear any leftover lines if new render is shorter. No clear-before-write = no flicker. if (previousRenderedLines > 0) await _terminal.WriteAsync($"\x1b[{previousRenderedLines}A\r"); await _terminal.WriteAsync(rendered); if (previousRenderedLines > renderedLines) await _terminal.WriteAsync("\x1b[J"); 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); } var events = await PlayAdventureWasmAsync(theme, 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(); } /// /// WASM-compatible adventure playback. Uses a queue-based pattern to bridge /// Loreline's synchronous callbacks with async terminal I/O. /// Loreline handlers store their continuation and signal the async loop, /// which processes the action (render, wait for input) then resumes Loreline. /// private async Task> PlayAdventureWasmAsync( AdventureTheme theme, string scriptContent, string? translationContent) { var events = new List(); string themeName = theme.ToString().ToLowerInvariant(); string adventureId = $"{themeName}/intro"; // Parse script var script = Loreline.Engine.Parse( scriptContent, $"{themeName}/intro.lor", (path, callback) => callback(string.Empty)); if (script is null) { await ShowErrorAsync("Failed to parse adventure script."); return events; } // Build interpreter options var options = Loreline.Interpreter.InterpreterOptions.Default(); // Build custom functions using AdventureEngine's public method. // We use a dummy renderer that does nothing for ShowMessage calls inside custom functions // (events are tracked in the events list and displayed elsewhere). var dummyRenderer = new NoOpRenderer(); var engineHelper = new AdventureEngine(dummyRenderer, _loc); options.Functions = engineHelper.BuildCustomFunctions(_state, events); if (translationContent is not null) { var translationScript = Loreline.Engine.Parse(translationContent); if (translationScript is not null) options.Translations = Loreline.Engine.ExtractTranslations(translationScript); } // Queue-based async bridge for Loreline callbacks // Each handler stores its data + continuation, then signals the loop Action? pendingContinuation = null; string? pendingDialogueChar = null; string? pendingDialogueText = null; List? pendingChoiceOptions = null; List? pendingChoiceEnabled = null; List? pendingChoiceHints = null; Action? pendingChoiceCallback = null; bool finished = false; var actionReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); // Dialogue handler: store callback, DON'T call it — let the async loop handle it void HandleDialogue(Loreline.Interpreter.Dialogue dialogue) { string? displayName = dialogue.Character; if (displayName is not null) { string characterName = dialogue.Interpreter.GetCharacterField(displayName, "name") as string ?? displayName; string key = $"character.{characterName.ToLowerInvariant().Replace(" ", "_").Replace("'", "")}"; string localized = _loc.Get(key); displayName = !localized.StartsWith("[MISSING:") ? localized : characterName; } pendingDialogueChar = displayName; pendingDialogueText = dialogue.Text; pendingContinuation = () => dialogue.Callback(); actionReady.TrySetResult(); } // Choice handler: store options + callback, DON'T call it void HandleChoice(Loreline.Interpreter.Choice choice) { var opts = new List(); var enabled = new List(); var hints = new List(); foreach (var opt in choice.Options) { var (text, hint) = HintSeparator.Parse(opt.Text); if (opt.Enabled) { opts.Add(text); enabled.Add(true); hints.Add(null); } else { string prefix = hint ?? _loc.Get("adventure.unavailable"); opts.Add($"({prefix}) {text}"); enabled.Add(false); hints.Add(hint); } } pendingChoiceOptions = opts; pendingChoiceEnabled = enabled; pendingChoiceHints = hints; pendingChoiceCallback = idx => choice.Callback(idx); pendingContinuation = null; // signal that this is a choice, not a dialogue actionReady.TrySetResult(); } void HandleFinish(Loreline.Interpreter.Finish _) { finished = true; actionReady.TrySetResult(); } // Check for saved progress bool hasSave = _state.AdventureSaveData.TryGetValue(adventureId, out string? saveData) && !string.IsNullOrEmpty(saveData); Loreline.Interpreter interpreter; if (hasSave) { interpreter = Loreline.Engine.Resume( script, HandleDialogue, HandleChoice, HandleFinish, saveData!, options: options); } else { interpreter = Loreline.Engine.Play( script, HandleDialogue, HandleChoice, HandleFinish, options: options); } // Async loop: process queued actions until the adventure finishes while (!finished) { await actionReady.Task; actionReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); if (finished) break; if (pendingChoiceCallback is not null) { // Choice action: show options, get selection, resume Loreline var choiceOpts = pendingChoiceOptions!; var choiceEnabled = pendingChoiceEnabled!; var choiceHints = pendingChoiceHints!; var choiceCb = pendingChoiceCallback; pendingChoiceCallback = null; pendingChoiceOptions = null; pendingChoiceEnabled = null; pendingChoiceHints = null; int selectedIndex; while (true) { selectedIndex = await _terminal.ShowSelectionAsync( "", choiceOpts, _renderContext.HasArrowSelection); if (choiceEnabled[selectedIndex]) break; if (choiceHints[selectedIndex] is { } hintText) await _terminal.WriteMarkupLineAsync($"[dim italic]{Spectre.Console.Markup.Escape(hintText)}[/]"); await ShowErrorAsync(_loc.Get("adventure.unavailable")); } choiceCb(selectedIndex); } else if (pendingContinuation is not null) { // Dialogue action: show text, wait for key, resume Loreline await ShowAdventureDialogueAsync(pendingDialogueChar, pendingDialogueText!); await WaitForKeyAsync(); var cont = pendingContinuation; pendingContinuation = null; pendingDialogueChar = null; pendingDialogueText = null; cont(); } } // Adventure completed _state.AdventureSaveData.Remove(adventureId); _state.CompletedAdventures.Add(theme.ToString()); return events; } // ── 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() { // Top row: Stats (30) | Resources (30) | Completion (60) — total 120 var topRow = new Table().NoBorder().HideHeaders().Expand(); topRow.AddColumn(new TableColumn("c1").Width(30)); topRow.AddColumn(new TableColumn("c2").Width(30)); topRow.AddColumn(new TableColumn("c3").Width(60)); IRenderable statsPanel = _renderContext.HasStatsPanel ? StatsPanel.Render(_state, _loc) : new Panel($"[dim italic]{Markup.Escape(_loc.Get("panel.locked.stats"))}[/]").Header("Stats").Expand(); IRenderable resourcePanel = _renderContext.HasResourcePanel ? ResourcePanel.Render(_state, _loc, barWidth: 8) : new Panel($"[dim italic]{Markup.Escape(_loc.Get("panel.locked.resources"))}[/]") .Header(Markup.Escape(_loc.Get("resource.title"))).Expand(); var completionItems = new List(); if (_renderContext.HasCompletionTracker) { // Compact: single line with percent + hint joined string completionLine = _loc.Get("ui.completion", _renderContext.CompletionPercent); if (_renderContext.NextHints.Count > 0) completionLine += " — " + _renderContext.NextHints[0]; completionItems.Add(new Markup($"[bold cyan]{Markup.Escape(completionLine)}[/]")); // Additional hints on separate lines for (int i = 1; i < _renderContext.NextHints.Count; i++) completionItems.Add(new Markup($" [dim]{Markup.Escape(_renderContext.NextHints[i])}[/]")); } IRenderable completionPanel = completionItems.Count > 0 ? new Rows(completionItems) : new Text(""); topRow.AddRow(statsPanel, resourcePanel, completionPanel); await _terminal.WriteRenderableAsync(topRow); // Bottom row: Inventory (60) | Portrait + Crafting (60) — total 120 var botRow = new Table().NoBorder().HideHeaders().Expand(); botRow.AddColumn(new TableColumn("c1").Width(60)); botRow.AddColumn(new TableColumn("c2").Width(60)); 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.HasPortraitPanel) rightItems.Add(PortraitPanel.Render(_state.Appearance, useColors: _renderContext.HasColors)); else rightItems.Add(new Panel("[dim]?[/]").Header("Portrait").Expand()); if (_renderContext.HasCraftingPanel) rightItems.Add(CraftingPanel.Render(_state, _registry, _loc)); botRow.AddRow(leftPanel, new Rows(rightItems)); 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. /// No-op renderer used only to satisfy AdventureEngine.BuildCustomFunctions() parameter. /// Custom function messages (e.g. "You received X") are not shown in WASM — the adventure /// dialogue provides the narrative, and events are tracked in the events list. /// internal sealed class NoOpRenderer : IRenderer { public void ShowMessage(string message) { } public void ShowError(string message) { } 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) { } public int ShowAdventureChoice(List options) => 0; public void ShowAdventureHint(string hint) { } public void ShowInteraction(string description) { } public void WaitForKeyPress(string? message = null) { } public void Clear() { } public int ShowSelection(string prompt, List options) => 0; public string ShowTextInput(string prompt) => ""; } /// /// [DEPRECATED] Sync→async bridge for IRenderer. Kept for reference but no longer used /// by adventure playback (which now uses PlayAdventureWasmAsync directly). /// 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 ""; } }