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 ""; } }