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