openthebox/src/OpenTheBox.Web/WebGameHost.cs
2026-03-18 18:56:40 +01:00

1578 lines
61 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
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<string, string> _adventureScripts = new();
private Dictionary<string, string> _adventureTranslations = new();
// Pre-loaded localization strings
private Dictionary<string, string> _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<AdventureTheme>();
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<string> { "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<string>();
var actions = new List<string>();
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<string> { "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<GameEvent> events)
{
var autoConsumedIds = events.OfType<ItemConsumedEvent>().Select(e => e.InstanceId).ToHashSet();
var allLoot = new List<(string name, string rarity, string category)>();
var deferredMessages = new List<string>();
var consumedItemNames = new Dictionary<Guid, string>();
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<ItemReceivedEvent>()
.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<string>
{
_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();
}
/// <summary>
/// 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.
/// </summary>
private async Task<List<AdventureEvent>> PlayAdventureWasmAsync(
AdventureTheme theme, string scriptContent, string? translationContent)
{
var events = new List<AdventureEvent>();
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<string>? pendingChoiceOptions = null;
List<bool>? pendingChoiceEnabled = null;
List<string?>? pendingChoiceHints = null;
Action<int>? 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<string>();
var enabled = new List<bool>();
var hints = new List<string?>();
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<ItemReceivedEvent>().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<IRenderable>();
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<IRenderable>();
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<IRenderable>();
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<int> ShowAdventureChoiceAsync(List<string> 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<AdventureTheme>().Length;
var totalUIFeatures = Enum.GetValues<UIFeature>().Length;
var totalResources = Enum.GetValues<ResourceType>().Length;
var totalStats = Enum.GetValues<StatType>().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<string>();
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} ",
_ => ""
};
}
}
/// <summary>
/// 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.
/// </summary>
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<string> 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<string> options) => 0;
public string ShowTextInput(string prompt) => "";
}
/// <summary>
/// [DEPRECATED] Sync→async bridge for IRenderer. Kept for reference but no longer used
/// by adventure playback (which now uses PlayAdventureWasmAsync directly).
/// </summary>
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<string> 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<string> options)
{
// Not used by AdventureEngine
return 0;
}
public string ShowTextInput(string prompt)
{
// Not used by AdventureEngine
return "";
}
}