diff --git a/.gitignore b/.gitignore
index 3e811c3..ee1216c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,4 +43,7 @@ Thumbs.db
## Claude
.claude/
-builds
\ No newline at end of file
+builds
+
+## Web project: content files copied by build target (source of truth is content/)
+src/OpenTheBox.Web/wwwroot/content/
\ No newline at end of file
diff --git a/OpenTheBox.slnx b/OpenTheBox.slnx
index f1ed150..4d1887c 100644
--- a/OpenTheBox.slnx
+++ b/OpenTheBox.slnx
@@ -5,5 +5,6 @@
+
diff --git a/src/OpenTheBox.Web/OpenTheBox.Web.csproj b/src/OpenTheBox.Web/OpenTheBox.Web.csproj
index b19de00..0d144cf 100644
--- a/src/OpenTheBox.Web/OpenTheBox.Web.csproj
+++ b/src/OpenTheBox.Web/OpenTheBox.Web.csproj
@@ -16,20 +16,30 @@
-
+
-
- PreserveNewest
- wwwroot\content\data\%(RecursiveDir)%(Filename)%(Extension)
-
-
- PreserveNewest
- wwwroot\content\adventures\%(RecursiveDir)%(Filename)%(Extension)
-
-
- PreserveNewest
- wwwroot\content\strings\%(RecursiveDir)%(Filename)%(Extension)
-
+
+ ..\..\lib\Loreline.dll
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenTheBox.Web/Program.cs b/src/OpenTheBox.Web/Program.cs
index 8158da4..bba0209 100644
--- a/src/OpenTheBox.Web/Program.cs
+++ b/src/OpenTheBox.Web/Program.cs
@@ -20,6 +20,14 @@ public static class Program
var http = host.Services.GetRequiredService();
var gameHost = new WebGameHost(js, http);
- await gameHost.RunAsync();
+ try
+ {
+ await gameHost.RunAsync();
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"[OpenTheBox] Fatal error: {ex}");
+ throw;
+ }
}
}
diff --git a/src/OpenTheBox.Web/WebGameHost.cs b/src/OpenTheBox.Web/WebGameHost.cs
index 079e718..3b7ca7b 100644
--- a/src/OpenTheBox.Web/WebGameHost.cs
+++ b/src/OpenTheBox.Web/WebGameHost.cs
@@ -129,7 +129,7 @@ public sealed class WebGameHost
if (_locStrings.TryGetValue(key, out string? json))
_loc.LoadFromString(locale, json);
else
- _loc.Change(locale); // fallback to file-based (will fail gracefully in WASM)
+ _loc.LoadFromString(locale, "{}"); // no file I/O in WASM — use empty strings as fallback
}
// ── Main menu ────────────────────────────────────────────────────────
@@ -592,6 +592,7 @@ public sealed class WebGameHost
ColorSystem = ColorSystemSupport.TrueColor
});
bufferConsole.Profile.Width = WebTerminal.Width;
+ bufferConsole.Profile.Height = WebTerminal.Height;
bufferConsole.Write(InventoryPanel.Render(_state, _registry, _loc, scrollOffset, selectedIndex: selectedIndex));
@@ -785,11 +786,7 @@ public sealed class WebGameHost
_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);
+ var events = await PlayAdventureWasmAsync(theme, scriptContent, translationContent);
foreach (var evt in events)
{
@@ -826,6 +823,195 @@ public sealed class WebGameHost
await WaitForKeyAsync();
}
+ ///
+ /// WASM-compatible adventure playback. Uses a queue-based pattern to bridge
+ /// Loreline's synchronous callbacks with async terminal I/O.
+ /// Loreline handlers store their continuation and signal the async loop,
+ /// which processes the action (render, wait for input) then resumes Loreline.
+ ///
+ private async Task> PlayAdventureWasmAsync(
+ AdventureTheme theme, string scriptContent, string? translationContent)
+ {
+ var events = new List();
+ string themeName = theme.ToString().ToLowerInvariant();
+ string adventureId = $"{themeName}/intro";
+
+ // Parse script
+ var script = Loreline.Engine.Parse(
+ scriptContent,
+ $"{themeName}/intro.lor",
+ (path, callback) => callback(string.Empty));
+
+ if (script is null)
+ {
+ await ShowErrorAsync("Failed to parse adventure script.");
+ return events;
+ }
+
+ // Build interpreter options
+ var options = Loreline.Interpreter.InterpreterOptions.Default();
+
+ // Build custom functions using AdventureEngine's public method.
+ // We use a dummy renderer that does nothing for ShowMessage calls inside custom functions
+ // (events are tracked in the events list and displayed elsewhere).
+ var dummyRenderer = new NoOpRenderer();
+ var engineHelper = new AdventureEngine(dummyRenderer, _loc);
+ options.Functions = engineHelper.BuildCustomFunctions(_state, events);
+
+ if (translationContent is not null)
+ {
+ var translationScript = Loreline.Engine.Parse(translationContent);
+ if (translationScript is not null)
+ options.Translations = Loreline.Engine.ExtractTranslations(translationScript);
+ }
+
+ // Queue-based async bridge for Loreline callbacks
+ // Each handler stores its data + continuation, then signals the loop
+ Action? pendingContinuation = null;
+ string? pendingDialogueChar = null;
+ string? pendingDialogueText = null;
+ List? pendingChoiceOptions = null;
+ List? pendingChoiceEnabled = null;
+ List? pendingChoiceHints = null;
+ Action? pendingChoiceCallback = null;
+ bool finished = false;
+ var actionReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ // Dialogue handler: store callback, DON'T call it — let the async loop handle it
+ void HandleDialogue(Loreline.Interpreter.Dialogue dialogue)
+ {
+ string? displayName = dialogue.Character;
+ if (displayName is not null)
+ {
+ string characterName = dialogue.Interpreter.GetCharacterField(displayName, "name") as string
+ ?? displayName;
+ string key = $"character.{characterName.ToLowerInvariant().Replace(" ", "_").Replace("'", "")}";
+ string localized = _loc.Get(key);
+ displayName = !localized.StartsWith("[MISSING:") ? localized : characterName;
+ }
+
+ pendingDialogueChar = displayName;
+ pendingDialogueText = dialogue.Text;
+ pendingContinuation = () => dialogue.Callback();
+ actionReady.TrySetResult();
+ }
+
+ // Choice handler: store options + callback, DON'T call it
+ void HandleChoice(Loreline.Interpreter.Choice choice)
+ {
+ var opts = new List();
+ var enabled = new List();
+ var hints = new List();
+
+ foreach (var opt in choice.Options)
+ {
+ var (text, hint) = HintSeparator.Parse(opt.Text);
+ if (opt.Enabled)
+ {
+ opts.Add(text);
+ enabled.Add(true);
+ hints.Add(null);
+ }
+ else
+ {
+ string prefix = hint ?? _loc.Get("adventure.unavailable");
+ opts.Add($"({prefix}) {text}");
+ enabled.Add(false);
+ hints.Add(hint);
+ }
+ }
+
+ pendingChoiceOptions = opts;
+ pendingChoiceEnabled = enabled;
+ pendingChoiceHints = hints;
+ pendingChoiceCallback = idx => choice.Callback(idx);
+ pendingContinuation = null; // signal that this is a choice, not a dialogue
+ actionReady.TrySetResult();
+ }
+
+ void HandleFinish(Loreline.Interpreter.Finish _)
+ {
+ finished = true;
+ actionReady.TrySetResult();
+ }
+
+ // Check for saved progress
+ bool hasSave = _state.AdventureSaveData.TryGetValue(adventureId, out string? saveData)
+ && !string.IsNullOrEmpty(saveData);
+
+ Loreline.Interpreter interpreter;
+ if (hasSave)
+ {
+ interpreter = Loreline.Engine.Resume(
+ script, HandleDialogue, HandleChoice, HandleFinish,
+ saveData!, options: options);
+ }
+ else
+ {
+ interpreter = Loreline.Engine.Play(
+ script, HandleDialogue, HandleChoice, HandleFinish,
+ options: options);
+ }
+
+ // Async loop: process queued actions until the adventure finishes
+ while (!finished)
+ {
+ await actionReady.Task;
+ actionReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ if (finished) break;
+
+ if (pendingChoiceCallback is not null)
+ {
+ // Choice action: show options, get selection, resume Loreline
+ var choiceOpts = pendingChoiceOptions!;
+ var choiceEnabled = pendingChoiceEnabled!;
+ var choiceHints = pendingChoiceHints!;
+ var choiceCb = pendingChoiceCallback;
+ pendingChoiceCallback = null;
+ pendingChoiceOptions = null;
+ pendingChoiceEnabled = null;
+ pendingChoiceHints = null;
+
+ int selectedIndex;
+ while (true)
+ {
+ selectedIndex = await _terminal.ShowSelectionAsync(
+ "", choiceOpts, _renderContext.HasArrowSelection);
+
+ if (choiceEnabled[selectedIndex])
+ break;
+
+ if (choiceHints[selectedIndex] is { } hintText)
+ await _terminal.WriteMarkupLineAsync($"[dim italic]{Spectre.Console.Markup.Escape(hintText)}[/]");
+
+ await ShowErrorAsync(_loc.Get("adventure.unavailable"));
+ }
+
+ choiceCb(selectedIndex);
+ }
+ else if (pendingContinuation is not null)
+ {
+ // Dialogue action: show text, wait for key, resume Loreline
+ await ShowAdventureDialogueAsync(pendingDialogueChar, pendingDialogueText!);
+ await WaitForKeyAsync();
+
+ var cont = pendingContinuation;
+ pendingContinuation = null;
+ pendingDialogueChar = null;
+ pendingDialogueText = null;
+
+ cont();
+ }
+ }
+
+ // Adventure completed
+ _state.AdventureSaveData.Remove(adventureId);
+ _state.CompletedAdventures.Add(theme.ToString());
+
+ return events;
+ }
+
// ── Appearance ───────────────────────────────────────────────────────
private async Task ChangeAppearance()
@@ -1291,11 +1477,31 @@ public sealed class WebGameHost
///
/// 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.
+/// No-op renderer used only to satisfy AdventureEngine.BuildCustomFunctions() parameter.
+/// Custom function messages (e.g. "You received X") are not shown in WASM — the adventure
+/// dialogue provides the narrative, and events are tracked in the events list.
+///
+internal sealed class NoOpRenderer : IRenderer
+{
+ public void ShowMessage(string message) { }
+ public void ShowError(string message) { }
+ public void ShowBoxOpening(string boxName, string rarity) { }
+ public void ShowLootReveal(List<(string name, string rarity, string category)> items) { }
+ public void ShowGameState(GameState state, RenderContext context) { }
+ public void ShowUIFeatureUnlocked(string featureName) { }
+ public void ShowAdventureDialogue(string? character, string text) { }
+ public int ShowAdventureChoice(List options) => 0;
+ public void ShowAdventureHint(string hint) { }
+ public void ShowInteraction(string description) { }
+ public void WaitForKeyPress(string? message = null) { }
+ public void Clear() { }
+ public int ShowSelection(string prompt, List options) => 0;
+ public string ShowTextInput(string prompt) => "";
+}
+
+///
+/// [DEPRECATED] Sync→async bridge for IRenderer. Kept for reference but no longer used
+/// by adventure playback (which now uses PlayAdventureWasmAsync directly).
///
internal sealed class WebRendererAdapter : IRenderer
{
diff --git a/src/OpenTheBox.Web/WebTerminal.cs b/src/OpenTheBox.Web/WebTerminal.cs
index 02c6fa0..83920ad 100644
--- a/src/OpenTheBox.Web/WebTerminal.cs
+++ b/src/OpenTheBox.Web/WebTerminal.cs
@@ -14,11 +14,17 @@ public sealed class WebTerminal
private static WebTerminal? _instance;
private readonly IJSRuntime _js;
+
+ ///
+ /// Bounded key buffer — prevents held keys from accumulating minutes of input.
+ /// FullMode.DropWrite silently drops new keys when the buffer is full.
+ ///
private readonly Channel _keyChannel =
- Channel.CreateUnbounded(new UnboundedChannelOptions
+ Channel.CreateBounded(new BoundedChannelOptions(8)
{
SingleReader = true,
- SingleWriter = true
+ SingleWriter = true,
+ FullMode = BoundedChannelFullMode.DropOldest
});
public const int Width = 120;
@@ -77,6 +83,7 @@ public sealed class WebTerminal
ColorSystem = ColorSystemSupport.TrueColor
});
console.Profile.Width = Width;
+ console.Profile.Height = Height;
console.Write(renderable);
return writer.ToString();
}
@@ -105,6 +112,7 @@ public sealed class WebTerminal
ColorSystem = ColorSystemSupport.TrueColor
});
console.Profile.Width = Width;
+ console.Profile.Height = Height;
console.MarkupLine(markup);
string ansi = writer.ToString().Replace("\n", "\r\n");
await WriteAsync(ansi);
@@ -233,6 +241,7 @@ public sealed class WebTerminal
ColorSystem = ColorSystemSupport.TrueColor
});
console.Profile.Width = Width;
+ console.Profile.Height = Height;
if (prompt.Length > 0)
console.MarkupLine($"[bold]{Markup.Escape(prompt)}[/]");
@@ -253,7 +262,8 @@ public sealed class WebTerminal
console.MarkupLine("[dim] ▼ ...[/]");
string rendered = writer.ToString().Replace("\n", "\r\n");
- int lineCount = rendered.Split('\n').Length;
+ // Count actual line breaks — Split('\n').Length overcounts by 1 due to trailing newline
+ int lineCount = rendered.Count(c => c == '\n');
await WriteAsync(rendered);
diff --git a/src/OpenTheBox.Web/wwwroot/appsettings.json b/src/OpenTheBox.Web/wwwroot/appsettings.json
new file mode 100644
index 0000000..9e26dfe
--- /dev/null
+++ b/src/OpenTheBox.Web/wwwroot/appsettings.json
@@ -0,0 +1 @@
+{}
\ No newline at end of file
diff --git a/src/OpenTheBox.Web/wwwroot/js/terminal-interop.js b/src/OpenTheBox.Web/wwwroot/js/terminal-interop.js
index 3057929..214f866 100644
--- a/src/OpenTheBox.Web/wwwroot/js/terminal-interop.js
+++ b/src/OpenTheBox.Web/wwwroot/js/terminal-interop.js
@@ -25,7 +25,12 @@ window.terminalInterop = {
term.loadAddon(fitAddon);
term.open(document.getElementById('terminal'));
+
+ // Fit to container, but enforce minimum dimensions for game readability
fitAddon.fit();
+ if (term.cols < 120 || term.rows < 30) {
+ term.resize(Math.max(term.cols, 120), Math.max(term.rows, 30));
+ }
// Forward key input to C#
term.onData(function (data) {
@@ -35,6 +40,9 @@ window.terminalInterop = {
// Handle resize
window.addEventListener('resize', function () {
fitAddon.fit();
+ if (term.cols < 120 || term.rows < 30) {
+ term.resize(Math.max(term.cols, 120), Math.max(term.rows, 30));
+ }
});
this.term = term;
diff --git a/src/OpenTheBox/Adventures/AdventureEngine.cs b/src/OpenTheBox/Adventures/AdventureEngine.cs
index 051a628..036b9a9 100644
--- a/src/OpenTheBox/Adventures/AdventureEngine.cs
+++ b/src/OpenTheBox/Adventures/AdventureEngine.cs
@@ -30,7 +30,7 @@ public enum GameEventKind
///
/// In .lor files: Open the secret path|||A keen sense of Luck might help here... #label [if hasStat("Luck", 30)]
///
-internal static class HintSeparator
+public static class HintSeparator
{
public const string Delimiter = "|||";
@@ -77,6 +77,13 @@ public sealed class AdventureEngine
///
public async Task> PlayAdventure(AdventureTheme theme, GameState state)
{
+ // In WASM, use PlayAdventureFromContent() with pre-loaded scripts instead
+ if (OperatingSystem.IsBrowser())
+ {
+ _renderer.ShowError("PlayAdventure requires file I/O. Use PlayAdventureFromContent in WASM.");
+ return [];
+ }
+
string themeName = theme.ToString().ToLowerInvariant();
string scriptPath = Path.Combine(AdventuresRoot, themeName, "intro.lor");
@@ -338,7 +345,11 @@ public sealed class AdventureEngine
// ── Custom functions registered into the Loreline interpreter ────────
- private Dictionary BuildCustomFunctions(
+ ///
+ /// Builds the custom Loreline functions dictionary for a given game state.
+ /// Public so the WASM build can reuse it with its own async adventure loop.
+ ///
+ public Dictionary BuildCustomFunctions(
GameState state,
List events)
{
diff --git a/src/OpenTheBox/Data/ContentRegistry.cs b/src/OpenTheBox/Data/ContentRegistry.cs
index 1e8e78e..064c53d 100644
--- a/src/OpenTheBox/Data/ContentRegistry.cs
+++ b/src/OpenTheBox/Data/ContentRegistry.cs
@@ -42,6 +42,10 @@ public class ContentRegistry
public static ContentRegistry LoadFromFiles(
string itemsPath, string boxesPath, string interactionsPath, string? recipesPath = null)
{
+ if (OperatingSystem.IsBrowser())
+ throw new PlatformNotSupportedException(
+ "LoadFromFiles requires file I/O. Use LoadFromStrings in WASM.");
+
var registry = new ContentRegistry();
if (File.Exists(itemsPath))
diff --git a/src/OpenTheBox/Localization/LocalizationManager.cs b/src/OpenTheBox/Localization/LocalizationManager.cs
index 1a98418..b80f45c 100644
--- a/src/OpenTheBox/Localization/LocalizationManager.cs
+++ b/src/OpenTheBox/Localization/LocalizationManager.cs
@@ -35,6 +35,10 @@ public sealed class LocalizationManager
CurrentLocale = locale;
_strings = [];
+ // File I/O is not available in Blazor WASM — use LoadFromString() instead
+ if (OperatingSystem.IsBrowser())
+ return;
+
string localeName = locale.ToString().ToLowerInvariant();
string path = Path.Combine(StringsDirectory, $"{localeName}.json");
diff --git a/tests/OpenTheBox.Web.Tests/GameFlowTests.cs b/tests/OpenTheBox.Web.Tests/GameFlowTests.cs
new file mode 100644
index 0000000..de757ba
--- /dev/null
+++ b/tests/OpenTheBox.Web.Tests/GameFlowTests.cs
@@ -0,0 +1,393 @@
+using Microsoft.Playwright;
+
+namespace OpenTheBox.Web.Tests;
+
+///
+/// End-to-end tests that validate the web game is interactive and functional.
+/// These tests launch the Blazor WASM app and interact with the xterm.js terminal.
+///
+[Collection("WebApp")]
+public class GameFlowTests
+{
+ private readonly WebAppFixture _fixture;
+
+ public GameFlowTests(WebAppFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ private async Task<(IPage page, IBrowserContext context)> CreateFreshSessionAsync()
+ {
+ var context = await _fixture.Browser.NewContextAsync(new BrowserNewContextOptions
+ {
+ ViewportSize = new ViewportSize { Width = 1400, Height = 900 }
+ });
+ var page = await context.NewPageAsync();
+ return (page, context);
+ }
+
+ ///
+ /// Types a number and presses Enter for numbered selection prompts.
+ ///
+ private static async Task SelectNumberedOptionAsync(IPage page, int number)
+ {
+ await TerminalHelper.PressDigitAsync(page, number);
+ await Task.Delay(200);
+ await TerminalHelper.PressEnterAsync(page);
+ }
+
+ ///
+ /// Navigates through language selection → main menu → name entry → game loop.
+ /// Returns the page ready at the first action menu.
+ ///
+ private async Task StartNewGameAsync(IBrowserContext context, string playerName = "Tester")
+ {
+ var page = await context.NewPageAsync();
+ await page.GotoAsync(_fixture.BaseUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
+ await TerminalHelper.WaitForTerminalReadyAsync(page, timeoutMs: 90000);
+
+ // Language selection → English
+ await TerminalHelper.WaitForTerminalTextAsync(page, "Language", timeoutMs: 60000);
+ await SelectNumberedOptionAsync(page, 1);
+ await Task.Delay(500);
+
+ // Main menu → New Game
+ await TerminalHelper.WaitForTerminalTextAsync(page, "OPEN THE BOX", timeoutMs: 30000);
+ await SelectNumberedOptionAsync(page, 1);
+ await Task.Delay(500);
+
+ // Name prompt
+ await TerminalHelper.WaitForTerminalTextAsync(page, "name", timeoutMs: 15000);
+ await TerminalHelper.TypeAsync(page, playerName);
+ await TerminalHelper.PressEnterAsync(page);
+ await Task.Delay(500);
+
+ // Welcome message → press Enter
+ await TerminalHelper.WaitForTerminalTextAsync(page, playerName, timeoutMs: 10000);
+ await TerminalHelper.PressEnterAsync(page);
+ await Task.Delay(500);
+
+ // Wait for action menu
+ await TerminalHelper.WaitForTerminalTextAsync(page, "Open a box", timeoutMs: 15000);
+
+ return page;
+ }
+
+ ///
+ /// Opens a box from the action menu (selects "Open a box" → selects box #1 → presses through results).
+ ///
+ private static async Task OpenBoxAsync(IPage page)
+ {
+ // Select "Open a box" — find which option number it is
+ var content = await TerminalHelper.ReadTerminalAsync(page);
+
+ // "Open a box" should be option 1 in the action menu
+ await SelectNumberedOptionAsync(page, 1);
+ await Task.Delay(500);
+
+ // Select the first box in the list
+ await SelectNumberedOptionAsync(page, 1);
+ await Task.Delay(1000);
+
+ // Press Enter through box opening results
+ for (int i = 0; i < 3; i++)
+ {
+ await TerminalHelper.PressEnterAsync(page);
+ await Task.Delay(300);
+ }
+
+ // Wait for the action menu to reappear
+ await TerminalHelper.WaitForTerminalTextAsync(page, "Open a box", timeoutMs: 15000);
+ }
+
+ // ── Tests ──────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task PageLoads_TerminalInitializes()
+ {
+ var (page, context) = await CreateFreshSessionAsync();
+ var consoleLogs = new List();
+ page.Console += (_, msg) => consoleLogs.Add($"[{msg.Type}] {msg.Text}");
+ page.PageError += (_, error) => consoleLogs.Add($"[PAGE ERROR] {error}");
+ try
+ {
+ await page.GotoAsync(_fixture.BaseUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
+ await TerminalHelper.WaitForTerminalReadyAsync(page, timeoutMs: 90000);
+
+ var containerVisible = await page.EvaluateAsync(@"() => {
+ const el = document.getElementById('terminal-container');
+ return el && el.classList.contains('active');
+ }");
+ Assert.True(containerVisible, "Terminal container should be active");
+
+ var loadingHidden = await page.EvaluateAsync(@"() => {
+ const el = document.getElementById('loading');
+ return el && el.classList.contains('hidden');
+ }");
+ Assert.True(loadingHidden, "Loading screen should be hidden");
+
+ var cols = await page.EvaluateAsync("() => window.terminalInterop?.term?.cols ?? 0");
+ Assert.True(cols >= 120, $"Terminal should have at least 120 cols, got {cols}");
+
+ Assert.DoesNotContain(consoleLogs, l => l.StartsWith("[error]"));
+ }
+ finally
+ {
+ await context.CloseAsync();
+ }
+ }
+
+ [Fact]
+ public async Task LanguageSelection_DisplaysOptions()
+ {
+ var (page, context) = await CreateFreshSessionAsync();
+ try
+ {
+ await page.GotoAsync(_fixture.BaseUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
+ await TerminalHelper.WaitForTerminalReadyAsync(page, timeoutMs: 90000);
+
+ await TerminalHelper.WaitForTerminalTextAsync(page, "Language", timeoutMs: 60000);
+
+ var content = await TerminalHelper.ReadTerminalAsync(page);
+ Assert.Contains("English", content);
+ Assert.Contains("Fran", content);
+ }
+ finally
+ {
+ await context.CloseAsync();
+ }
+ }
+
+ [Fact]
+ public async Task FullFlow_SelectLanguage_EnterName_OpenFirstBox()
+ {
+ var (_, context) = await CreateFreshSessionAsync();
+ try
+ {
+ var page = await StartNewGameAsync(context);
+
+ // Verify initial state
+ var content = await TerminalHelper.ReadTerminalAsync(page);
+ Console.WriteLine($"Initial game state:\n{content}");
+ Assert.Contains("Boxes Opened: 0", content);
+
+ // Open the first box
+ await OpenBoxAsync(page);
+
+ // Verify box was opened
+ var afterOpen = await TerminalHelper.ReadTerminalAsync(page);
+ Console.WriteLine($"After first box:\n{afterOpen}");
+ Assert.Contains("Boxes Opened: 1", afterOpen);
+ }
+ finally
+ {
+ await context.CloseAsync();
+ }
+ }
+
+ [Fact]
+ public async Task MultipleBoxes_ProgressionAndMetaUnlocks()
+ {
+ var (_, context) = await CreateFreshSessionAsync();
+ var consoleLogs = new List();
+ try
+ {
+ var page = await StartNewGameAsync(context);
+ page.Console += (_, msg) => consoleLogs.Add($"[{msg.Type}] {msg.Text}");
+
+ // Open several boxes — should trigger meta unlocks (new UI features)
+ // The pacing test says 1st meta UI unlock should happen before box ~50
+ int boxesOpened = 0;
+ bool sawNewLabel = false;
+ bool sawAdventure = false;
+
+ for (int i = 0; i < 20; i++)
+ {
+ await OpenBoxAsync(page);
+ boxesOpened++;
+
+ var content = await TerminalHelper.ReadTerminalAsync(page);
+
+ // Check for NEW labels (meta unlocks) or adventure option
+ if (content.Contains("NEW", StringComparison.Ordinal))
+ sawNewLabel = true;
+ if (content.Contains("adventure", StringComparison.OrdinalIgnoreCase))
+ sawAdventure = true;
+
+ Console.WriteLine($"Box #{boxesOpened}: menu options visible. NEW={sawNewLabel}, Adventure={sawAdventure}");
+
+ // If we've seen both, no need to open more
+ if (sawNewLabel && sawAdventure)
+ break;
+ }
+
+ Console.WriteLine($"After {boxesOpened} boxes: NEW labels seen={sawNewLabel}, Adventure unlocked={sawAdventure}");
+
+ // Verify no browser errors from box opening
+ var errors = consoleLogs.Where(l => l.StartsWith("[error]")).ToList();
+ foreach (var err in errors)
+ Console.WriteLine($"Browser error: {err}");
+ Assert.Empty(errors);
+ }
+ finally
+ {
+ await context.CloseAsync();
+ }
+ }
+
+ [Fact]
+ public async Task ExtendedPlay_NoWasmCrash()
+ {
+ var (_, context) = await CreateFreshSessionAsync();
+ var consoleLogs = new List();
+ try
+ {
+ var page = await StartNewGameAsync(context);
+ page.Console += (_, msg) => consoleLogs.Add($"[{msg.Type}] {msg.Text}");
+
+ // Open 20+ boxes — should trigger meta unlocks, arrow navigation, adventures
+ // This validates that the full game loop works in WASM without crashing
+ for (int i = 0; i < 25; i++)
+ {
+ await OpenBoxAsync(page);
+
+ // Check for fatal errors after each box
+ bool hasFatalError = consoleLogs.Any(l => l.Contains("[OpenTheBox] Fatal error"));
+ if (hasFatalError)
+ {
+ var errors = consoleLogs.Where(l => l.Contains("error", StringComparison.OrdinalIgnoreCase));
+ foreach (var err in errors)
+ Console.WriteLine($"WASM error: {err}");
+ Assert.Fail($"Fatal error after box #{i + 1}");
+ }
+ }
+
+ var finalContent = await TerminalHelper.ReadTerminalAsync(page);
+ Console.WriteLine($"After 25 boxes:\n{finalContent}");
+
+ // After 25 boxes, the game should still be running and show the action menu
+ Assert.Contains("box", finalContent, StringComparison.OrdinalIgnoreCase);
+
+ // Verify no WASM-specific errors
+ bool hasMonitorError = consoleLogs.Any(l => l.Contains("Cannot wait on monitors"));
+ Assert.False(hasMonitorError, "Should not get 'Cannot wait on monitors' error in WASM");
+
+ bool hasPlatformError = consoleLogs.Any(l => l.Contains("PlatformNotSupported"));
+ Assert.False(hasPlatformError, "Should not get PlatformNotSupportedException in WASM");
+
+ Console.WriteLine($"25 boxes opened successfully. Browser console had {consoleLogs.Count} messages.");
+ }
+ finally
+ {
+ await context.CloseAsync();
+ }
+ }
+
+ [Fact]
+ public async Task Adventure_NoWasmCrash()
+ {
+ var (_, context) = await CreateFreshSessionAsync();
+ var consoleLogs = new List();
+ try
+ {
+ var page = await StartNewGameAsync(context);
+ page.Console += (_, msg) => consoleLogs.Add($"[{msg.Type}] {msg.Text}");
+
+ // Open boxes until adventure is unlocked
+ bool adventureUnlocked = false;
+ for (int i = 0; i < 30 && !adventureUnlocked; i++)
+ {
+ await OpenBoxAsync(page);
+ var content = await TerminalHelper.ReadTerminalAsync(page);
+ adventureUnlocked = content.Contains("adventure", StringComparison.OrdinalIgnoreCase);
+ }
+
+ if (!adventureUnlocked)
+ {
+ Console.WriteLine("Adventure not unlocked within 30 boxes — skipping");
+ return;
+ }
+
+ Console.WriteLine("Adventure unlocked, attempting to start...");
+
+ // Navigate to adventure: use arrow keys to find "adventure" option
+ // Try each digit from 1-9 — the arrow selection responds to digit shortcuts
+ for (int d = 1; d <= 9; d++)
+ {
+ await TerminalHelper.PressDigitAsync(page, d);
+ await Task.Delay(1000);
+
+ var content = await TerminalHelper.ReadTerminalAsync(page);
+ // If we see the adventure list (contains "Back" and a theme name), we found it
+ if (content.Contains("Back", StringComparison.OrdinalIgnoreCase)
+ && !content.Contains("Open a box", StringComparison.OrdinalIgnoreCase))
+ {
+ Console.WriteLine($"Adventure menu found with digit {d}");
+ break;
+ }
+
+ // If we accidentally selected something else, press Escape or go back
+ if (!content.Contains("adventure", StringComparison.OrdinalIgnoreCase))
+ {
+ // We're in a submenu we don't want — go back by selecting "Back" option
+ // First check if this is a box selection or other menu
+ if (content.Contains("Back"))
+ {
+ // Find the Back option number and press it
+ var backLines = content.Split('\n');
+ foreach (var line in backLines)
+ {
+ if (line.Contains("Back"))
+ {
+ foreach (var ch in line)
+ {
+ if (char.IsDigit(ch))
+ {
+ await TerminalHelper.PressDigitAsync(page, ch - '0');
+ await Task.Delay(500);
+ break;
+ }
+ }
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ // Try to select the first adventure
+ await TerminalHelper.PressDigitAsync(page, 1);
+ await Task.Delay(2000);
+
+ // Read the result
+ var dialogueContent = await TerminalHelper.ReadTerminalAsync(page);
+ Console.WriteLine($"After adventure selection:\n{dialogueContent}");
+
+ // The key assertion: no WASM-specific crash errors
+ bool hasMonitorError = consoleLogs.Any(l => l.Contains("Cannot wait on monitors"));
+ Assert.False(hasMonitorError, "Should not get 'Cannot wait on monitors' error in WASM");
+
+ bool hasFatalError = consoleLogs.Any(l => l.Contains("[OpenTheBox] Fatal error"));
+ if (hasFatalError)
+ {
+ var errors = consoleLogs.Where(l => l.Contains("error", StringComparison.OrdinalIgnoreCase));
+ foreach (var err in errors)
+ Console.WriteLine($"Browser error: {err}");
+ }
+ Assert.False(hasFatalError, "Should not get fatal error during adventure");
+
+ // If we got this far with dialogue, press through a few lines
+ for (int i = 0; i < 5; i++)
+ {
+ await TerminalHelper.PressEnterAsync(page);
+ await Task.Delay(500);
+ }
+
+ Console.WriteLine("Adventure test completed without WASM crash");
+ }
+ finally
+ {
+ await context.CloseAsync();
+ }
+ }
+}
diff --git a/tests/OpenTheBox.Web.Tests/OpenTheBox.Web.Tests.csproj b/tests/OpenTheBox.Web.Tests/OpenTheBox.Web.Tests.csproj
new file mode 100644
index 0000000..205a4d4
--- /dev/null
+++ b/tests/OpenTheBox.Web.Tests/OpenTheBox.Web.Tests.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/OpenTheBox.Web.Tests/TerminalHelper.cs b/tests/OpenTheBox.Web.Tests/TerminalHelper.cs
new file mode 100644
index 0000000..d38cff4
--- /dev/null
+++ b/tests/OpenTheBox.Web.Tests/TerminalHelper.cs
@@ -0,0 +1,107 @@
+using Microsoft.Playwright;
+
+namespace OpenTheBox.Web.Tests;
+
+///
+/// Helpers for interacting with the xterm.js terminal in Playwright tests.
+///
+public static class TerminalHelper
+{
+ ///
+ /// Reads the visible text content from the xterm.js terminal buffer.
+ ///
+ public static async Task ReadTerminalAsync(IPage page)
+ {
+ return await page.EvaluateAsync(@"() => {
+ const term = window.terminalInterop?.term;
+ if (!term) return '';
+ const buffer = term.buffer.active;
+ let text = '';
+ for (let i = 0; i < buffer.length; i++) {
+ const line = buffer.getLine(i);
+ if (line) text += line.translateToString(true) + '\n';
+ }
+ return text;
+ }");
+ }
+
+ ///
+ /// Waits until the terminal contains the specified text, with timeout.
+ ///
+ public static async Task WaitForTerminalTextAsync(IPage page, string text, int timeoutMs = 30000)
+ {
+ var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
+ while (DateTime.UtcNow < deadline)
+ {
+ var content = await ReadTerminalAsync(page);
+ if (content.Contains(text, StringComparison.OrdinalIgnoreCase))
+ return;
+ await Task.Delay(500);
+ }
+ // Read one final time for the error message
+ var finalContent = await ReadTerminalAsync(page);
+ throw new TimeoutException(
+ $"Terminal did not contain \"{text}\" within {timeoutMs}ms.\n" +
+ $"Terminal content:\n{finalContent}");
+ }
+
+ ///
+ /// Types text into the terminal (sends keystrokes).
+ ///
+ public static async Task TypeAsync(IPage page, string text)
+ {
+ await page.Keyboard.TypeAsync(text, new KeyboardTypeOptions { Delay = 50 });
+ }
+
+ ///
+ /// Presses Enter in the terminal.
+ ///
+ public static async Task PressEnterAsync(IPage page)
+ {
+ await page.Keyboard.PressAsync("Enter");
+ }
+
+ ///
+ /// Presses a specific key (Arrow keys, Escape, etc.).
+ ///
+ public static async Task PressKeyAsync(IPage page, string key)
+ {
+ await page.Keyboard.PressAsync(key);
+ }
+
+ ///
+ /// Sends a digit key press (1-9) for numbered menu selection.
+ ///
+ public static async Task PressDigitAsync(IPage page, int digit)
+ {
+ await page.Keyboard.PressAsync($"Digit{digit}");
+ }
+
+ ///
+ /// Waits for the terminal to be initialized (xterm.js loaded and terminal visible).
+ ///
+ public static async Task WaitForTerminalReadyAsync(IPage page, int timeoutMs = 60000)
+ {
+ var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
+ while (DateTime.UtcNow < deadline)
+ {
+ var ready = await page.EvaluateAsync(@"() => {
+ return window.terminalInterop?.term != null;
+ }");
+ if (ready)
+ {
+ // Verify terminal has proper dimensions (should be 120x30 after init)
+ var cols = await page.EvaluateAsync("() => window.terminalInterop.term.cols");
+ if (cols < 120)
+ {
+ // Force resize if fit addon miscalculated (e.g., headless browser)
+ await page.EvaluateAsync("() => window.terminalInterop.term.resize(120, 30)");
+ await Task.Delay(200);
+ }
+ return;
+ }
+ await Task.Delay(500);
+ }
+ throw new TimeoutException($"Terminal was not initialized within {timeoutMs}ms");
+ }
+}
diff --git a/tests/OpenTheBox.Web.Tests/WebAppFixture.cs b/tests/OpenTheBox.Web.Tests/WebAppFixture.cs
new file mode 100644
index 0000000..ee224b8
--- /dev/null
+++ b/tests/OpenTheBox.Web.Tests/WebAppFixture.cs
@@ -0,0 +1,82 @@
+using System.Diagnostics;
+using Microsoft.Playwright;
+
+namespace OpenTheBox.Web.Tests;
+
+///
+/// Shared fixture that starts the Blazor WASM dev server and Playwright browser once per test collection.
+///
+public class WebAppFixture : IAsyncLifetime
+{
+ private Process? _serverProcess;
+ public IBrowser Browser { get; private set; } = null!;
+ public IPlaywright Playwright { get; private set; } = null!;
+ public string BaseUrl { get; private set; } = "http://localhost:5280";
+
+ public async Task InitializeAsync()
+ {
+ // Start the Blazor WASM dev server
+ _serverProcess = new Process
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ FileName = "dotnet",
+ Arguments = $"run --project src/OpenTheBox.Web --urls {BaseUrl}",
+ WorkingDirectory = FindSolutionRoot(),
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ }
+ };
+ _serverProcess.Start();
+
+ // Wait for the server to be ready (try connecting)
+ using var httpClient = new HttpClient();
+ for (int i = 0; i < 120; i++) // up to 2 minutes for WASM compilation
+ {
+ try
+ {
+ var response = await httpClient.GetAsync(BaseUrl);
+ if (response.IsSuccessStatusCode) break;
+ }
+ catch { }
+ await Task.Delay(1000);
+ }
+
+ // Initialize Playwright
+ Playwright = await Microsoft.Playwright.Playwright.CreateAsync();
+ Browser = await Playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
+ {
+ Headless = true,
+ Args = ["--window-size=1400,900"]
+ });
+ }
+
+ public async Task DisposeAsync()
+ {
+ await Browser.DisposeAsync();
+ Playwright.Dispose();
+
+ if (_serverProcess is { HasExited: false })
+ {
+ _serverProcess.Kill(entireProcessTree: true);
+ _serverProcess.Dispose();
+ }
+ }
+
+ private static string FindSolutionRoot()
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir != null)
+ {
+ if (dir.GetFiles("OpenTheBox.slnx").Length > 0)
+ return dir.FullName;
+ dir = dir.Parent;
+ }
+ throw new InvalidOperationException("Could not find solution root (OpenTheBox.slnx)");
+ }
+}
+
+[CollectionDefinition("WebApp")]
+public class WebAppCollection : ICollectionFixture;
diff --git a/tests/snapshots/item_utility_report.txt b/tests/snapshots/item_utility_report.txt
index 87236e4..f3c24a7 100644
--- a/tests/snapshots/item_utility_report.txt
+++ b/tests/snapshots/item_utility_report.txt
@@ -1,5 +1,5 @@
# Item Utility Report
-# Total items: 152
+# Total items: 151
# Total boxes: 31
# Total recipes: 18
@@ -439,7 +439,7 @@
Adventure: Pirate
Interaction: pirate_map_compass
-## Material (15 items)
+## Material (14 items)
────────────────────────────────────────────────────────────────────────────────
[****] material_bronze_ingot (Uncommon) — Bronze
Loot: box_ok_tier
@@ -502,8 +502,6 @@
[*] material_diamond_gem (Legendary) — Diamant
Loot: box_black
- [NO USE] material_wood_nail (Common) — Bois
-
## Meta (33 items)
────────────────────────────────────────────────────────────────────────────────
[**] meta_colors (Rare) — Couleurs de texte
@@ -675,4 +673,4 @@
## Orphan Items (no usage context)
────────────────────────────────────────────────────────────────────────────────
- material_wood_nail (Material, Common)
+ (none)