Fix WASM compatibility issues and arrow selection rendering bug

- Fix arrow selection line count calculation (overcounted by 1 due to
  trailing newline, causing progressive line truncation on each redraw)
- Replace sync Loreline bridge with queue-based async pattern to avoid
  "Cannot wait on monitors" WASM deadlock
- Add bounded input buffer (8 keys, DropOldest) to prevent held-key
  accumulation
- Set Spectre.Console Profile.Height on all AnsiConsole.Create calls to
  prevent PlatformNotSupportedException on Console.WindowHeight
- Add explicit Loreline.dll reference + TrimmerRootAssembly for WASM
- Use MSBuild CopyGameContent target instead of Content/Link for static
  file serving in Blazor WASM
- Add WASM guards for file I/O in ContentRegistry, LocalizationManager,
  AdventureEngine
- Enforce min 120x30 terminal dimensions in xterm.js
- Add Playwright E2E tests (6 tests: page load, language selection,
  full flow, multi-box progression, extended play, adventure)
This commit is contained in:
Samuel Bouchet 2026-03-16 18:21:31 +01:00
parent ea487cd332
commit 2c43e31605
16 changed files with 903 additions and 36 deletions

3
.gitignore vendored
View file

@ -44,3 +44,6 @@ Thumbs.db
.claude/ .claude/
builds builds
## Web project: content files copied by build target (source of truth is content/)
src/OpenTheBox.Web/wwwroot/content/

View file

@ -5,5 +5,6 @@
</Folder> </Folder>
<Folder Name="/tests/"> <Folder Name="/tests/">
<Project Path="tests/OpenTheBox.Tests/OpenTheBox.Tests.csproj" /> <Project Path="tests/OpenTheBox.Tests/OpenTheBox.Tests.csproj" />
<Project Path="tests/OpenTheBox.Web.Tests/OpenTheBox.Web.Tests.csproj" />
</Folder> </Folder>
</Solution> </Solution>

View file

@ -16,20 +16,30 @@
<ProjectReference Include="..\OpenTheBox\OpenTheBox.csproj" /> <ProjectReference Include="..\OpenTheBox\OpenTheBox.csproj" />
</ItemGroup> </ItemGroup>
<!-- Copy game content files to wwwroot for HTTP loading --> <!-- Loreline DLL: must be explicitly referenced for WASM (transitive ref from OpenTheBox doesn't resolve for browser-wasm) -->
<ItemGroup> <ItemGroup>
<Content Include="..\..\content\data\**\*"> <Reference Include="Loreline">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <HintPath>..\..\lib\Loreline.dll</HintPath>
<Link>wwwroot\content\data\%(RecursiveDir)%(Filename)%(Extension)</Link> </Reference>
</Content> <TrimmerRootAssembly Include="Loreline" />
<Content Include="..\..\content\adventures\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>wwwroot\content\adventures\%(RecursiveDir)%(Filename)%(Extension)</Link>
</Content>
<Content Include="..\..\content\strings\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>wwwroot\content\strings\%(RecursiveDir)%(Filename)%(Extension)</Link>
</Content>
</ItemGroup> </ItemGroup>
<!-- Copy game content files into wwwroot before build so Blazor serves them as static files -->
<Target Name="CopyGameContent" BeforeTargets="BeforeBuild">
<ItemGroup>
<GameDataFiles Include="$(MSBuildProjectDirectory)\..\..\content\data\**\*" />
<GameAdventureFiles Include="$(MSBuildProjectDirectory)\..\..\content\adventures\**\*" />
<GameStringFiles Include="$(MSBuildProjectDirectory)\..\..\content\strings\**\*" />
</ItemGroup>
<Copy SourceFiles="@(GameDataFiles)"
DestinationFiles="@(GameDataFiles->'$(MSBuildProjectDirectory)\wwwroot\content\data\%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
<Copy SourceFiles="@(GameAdventureFiles)"
DestinationFiles="@(GameAdventureFiles->'$(MSBuildProjectDirectory)\wwwroot\content\adventures\%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
<Copy SourceFiles="@(GameStringFiles)"
DestinationFiles="@(GameStringFiles->'$(MSBuildProjectDirectory)\wwwroot\content\strings\%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
</Target>
</Project> </Project>

View file

@ -20,6 +20,14 @@ public static class Program
var http = host.Services.GetRequiredService<HttpClient>(); var http = host.Services.GetRequiredService<HttpClient>();
var gameHost = new WebGameHost(js, http); var gameHost = new WebGameHost(js, http);
await gameHost.RunAsync(); try
{
await gameHost.RunAsync();
}
catch (Exception ex)
{
Console.Error.WriteLine($"[OpenTheBox] Fatal error: {ex}");
throw;
}
} }
} }

View file

@ -129,7 +129,7 @@ public sealed class WebGameHost
if (_locStrings.TryGetValue(key, out string? json)) if (_locStrings.TryGetValue(key, out string? json))
_loc.LoadFromString(locale, json); _loc.LoadFromString(locale, json);
else 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 ──────────────────────────────────────────────────────── // ── Main menu ────────────────────────────────────────────────────────
@ -592,6 +592,7 @@ public sealed class WebGameHost
ColorSystem = ColorSystemSupport.TrueColor ColorSystem = ColorSystemSupport.TrueColor
}); });
bufferConsole.Profile.Width = WebTerminal.Width; bufferConsole.Profile.Width = WebTerminal.Width;
bufferConsole.Profile.Height = WebTerminal.Height;
bufferConsole.Write(InventoryPanel.Render(_state, _registry, _loc, scrollOffset, selectedIndex: selectedIndex)); bufferConsole.Write(InventoryPanel.Render(_state, _registry, _loc, scrollOffset, selectedIndex: selectedIndex));
@ -785,11 +786,7 @@ public sealed class WebGameHost
_adventureTranslations.TryGetValue(themeName, out translationContent); _adventureTranslations.TryGetValue(themeName, out translationContent);
} }
// Create a web-compatible renderer adapter for AdventureEngine var events = await PlayAdventureWasmAsync(theme, scriptContent, translationContent);
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) foreach (var evt in events)
{ {
@ -826,6 +823,195 @@ public sealed class WebGameHost
await WaitForKeyAsync(); 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 ─────────────────────────────────────────────────────── // ── Appearance ───────────────────────────────────────────────────────
private async Task ChangeAppearance() private async Task ChangeAppearance()
@ -1291,11 +1477,31 @@ public sealed class WebGameHost
/// <summary> /// <summary>
/// Adapter that implements IRenderer by delegating to WebGameHost's async methods. /// Adapter that implements IRenderer by delegating to WebGameHost's async methods.
/// Used by AdventureEngine which requires a synchronous IRenderer. /// No-op renderer used only to satisfy AdventureEngine.BuildCustomFunctions() parameter.
/// The adventure callbacks (HandleDialogue/HandleChoice) are called synchronously /// Custom function messages (e.g. "You received X") are not shown in WASM — the adventure
/// by Loreline, so this adapter blocks on the async methods. /// dialogue provides the narrative, and events are tracked in the events list.
/// In WASM single-threaded mode, this works because Loreline callbacks /// </summary>
/// resume execution synchronously via TaskCompletionSource. 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> /// </summary>
internal sealed class WebRendererAdapter : IRenderer internal sealed class WebRendererAdapter : IRenderer
{ {

View file

@ -14,11 +14,17 @@ public sealed class WebTerminal
private static WebTerminal? _instance; private static WebTerminal? _instance;
private readonly IJSRuntime _js; private readonly IJSRuntime _js;
/// <summary>
/// Bounded key buffer — prevents held keys from accumulating minutes of input.
/// FullMode.DropWrite silently drops new keys when the buffer is full.
/// </summary>
private readonly Channel<ConsoleKeyInfo> _keyChannel = private readonly Channel<ConsoleKeyInfo> _keyChannel =
Channel.CreateUnbounded<ConsoleKeyInfo>(new UnboundedChannelOptions Channel.CreateBounded<ConsoleKeyInfo>(new BoundedChannelOptions(8)
{ {
SingleReader = true, SingleReader = true,
SingleWriter = true SingleWriter = true,
FullMode = BoundedChannelFullMode.DropOldest
}); });
public const int Width = 120; public const int Width = 120;
@ -77,6 +83,7 @@ public sealed class WebTerminal
ColorSystem = ColorSystemSupport.TrueColor ColorSystem = ColorSystemSupport.TrueColor
}); });
console.Profile.Width = Width; console.Profile.Width = Width;
console.Profile.Height = Height;
console.Write(renderable); console.Write(renderable);
return writer.ToString(); return writer.ToString();
} }
@ -105,6 +112,7 @@ public sealed class WebTerminal
ColorSystem = ColorSystemSupport.TrueColor ColorSystem = ColorSystemSupport.TrueColor
}); });
console.Profile.Width = Width; console.Profile.Width = Width;
console.Profile.Height = Height;
console.MarkupLine(markup); console.MarkupLine(markup);
string ansi = writer.ToString().Replace("\n", "\r\n"); string ansi = writer.ToString().Replace("\n", "\r\n");
await WriteAsync(ansi); await WriteAsync(ansi);
@ -233,6 +241,7 @@ public sealed class WebTerminal
ColorSystem = ColorSystemSupport.TrueColor ColorSystem = ColorSystemSupport.TrueColor
}); });
console.Profile.Width = Width; console.Profile.Width = Width;
console.Profile.Height = Height;
if (prompt.Length > 0) if (prompt.Length > 0)
console.MarkupLine($"[bold]{Markup.Escape(prompt)}[/]"); console.MarkupLine($"[bold]{Markup.Escape(prompt)}[/]");
@ -253,7 +262,8 @@ public sealed class WebTerminal
console.MarkupLine("[dim] ▼ ...[/]"); console.MarkupLine("[dim] ▼ ...[/]");
string rendered = writer.ToString().Replace("\n", "\r\n"); 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); await WriteAsync(rendered);

View file

@ -0,0 +1 @@
{}

View file

@ -25,7 +25,12 @@ window.terminalInterop = {
term.loadAddon(fitAddon); term.loadAddon(fitAddon);
term.open(document.getElementById('terminal')); term.open(document.getElementById('terminal'));
// Fit to container, but enforce minimum dimensions for game readability
fitAddon.fit(); 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# // Forward key input to C#
term.onData(function (data) { term.onData(function (data) {
@ -35,6 +40,9 @@ window.terminalInterop = {
// Handle resize // Handle resize
window.addEventListener('resize', function () { window.addEventListener('resize', function () {
fitAddon.fit(); fitAddon.fit();
if (term.cols < 120 || term.rows < 30) {
term.resize(Math.max(term.cols, 120), Math.max(term.rows, 30));
}
}); });
this.term = term; this.term = term;

View file

@ -30,7 +30,7 @@ public enum GameEventKind
/// <example> /// <example>
/// In .lor files: Open the secret path|||A keen sense of Luck might help here... #label [if hasStat("Luck", 30)] /// In .lor files: Open the secret path|||A keen sense of Luck might help here... #label [if hasStat("Luck", 30)]
/// </example> /// </example>
internal static class HintSeparator public static class HintSeparator
{ {
public const string Delimiter = "|||"; public const string Delimiter = "|||";
@ -77,6 +77,13 @@ public sealed class AdventureEngine
/// </summary> /// </summary>
public async Task<List<AdventureEvent>> PlayAdventure(AdventureTheme theme, GameState state) public async Task<List<AdventureEvent>> 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 themeName = theme.ToString().ToLowerInvariant();
string scriptPath = Path.Combine(AdventuresRoot, themeName, "intro.lor"); string scriptPath = Path.Combine(AdventuresRoot, themeName, "intro.lor");
@ -338,7 +345,11 @@ public sealed class AdventureEngine
// ── Custom functions registered into the Loreline interpreter ──────── // ── Custom functions registered into the Loreline interpreter ────────
private Dictionary<string, Loreline.Interpreter.Function> BuildCustomFunctions( /// <summary>
/// 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.
/// </summary>
public Dictionary<string, Loreline.Interpreter.Function> BuildCustomFunctions(
GameState state, GameState state,
List<AdventureEvent> events) List<AdventureEvent> events)
{ {

View file

@ -42,6 +42,10 @@ public class ContentRegistry
public static ContentRegistry LoadFromFiles( public static ContentRegistry LoadFromFiles(
string itemsPath, string boxesPath, string interactionsPath, string? recipesPath = null) 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(); var registry = new ContentRegistry();
if (File.Exists(itemsPath)) if (File.Exists(itemsPath))

View file

@ -35,6 +35,10 @@ public sealed class LocalizationManager
CurrentLocale = locale; CurrentLocale = locale;
_strings = []; _strings = [];
// File I/O is not available in Blazor WASM — use LoadFromString() instead
if (OperatingSystem.IsBrowser())
return;
string localeName = locale.ToString().ToLowerInvariant(); string localeName = locale.ToString().ToLowerInvariant();
string path = Path.Combine(StringsDirectory, $"{localeName}.json"); string path = Path.Combine(StringsDirectory, $"{localeName}.json");

View file

@ -0,0 +1,393 @@
using Microsoft.Playwright;
namespace OpenTheBox.Web.Tests;
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// Types a number and presses Enter for numbered selection prompts.
/// </summary>
private static async Task SelectNumberedOptionAsync(IPage page, int number)
{
await TerminalHelper.PressDigitAsync(page, number);
await Task.Delay(200);
await TerminalHelper.PressEnterAsync(page);
}
/// <summary>
/// Navigates through language selection → main menu → name entry → game loop.
/// Returns the page ready at the first action menu.
/// </summary>
private async Task<IPage> 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;
}
/// <summary>
/// Opens a box from the action menu (selects "Open a box" → selects box #1 → presses through results).
/// </summary>
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<string>();
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<bool>(@"() => {
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<bool>(@"() => {
const el = document.getElementById('loading');
return el && el.classList.contains('hidden');
}");
Assert.True(loadingHidden, "Loading screen should be hidden");
var cols = await page.EvaluateAsync<int>("() => 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<string>();
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<string>();
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<string>();
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();
}
}
}

View file

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Microsoft.Playwright" Version="1.52.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,107 @@
using Microsoft.Playwright;
namespace OpenTheBox.Web.Tests;
/// <summary>
/// Helpers for interacting with the xterm.js terminal in Playwright tests.
/// </summary>
public static class TerminalHelper
{
/// <summary>
/// Reads the visible text content from the xterm.js terminal buffer.
/// </summary>
public static async Task<string> ReadTerminalAsync(IPage page)
{
return await page.EvaluateAsync<string>(@"() => {
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;
}");
}
/// <summary>
/// Waits until the terminal contains the specified text, with timeout.
/// </summary>
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}");
}
/// <summary>
/// Types text into the terminal (sends keystrokes).
/// </summary>
public static async Task TypeAsync(IPage page, string text)
{
await page.Keyboard.TypeAsync(text, new KeyboardTypeOptions { Delay = 50 });
}
/// <summary>
/// Presses Enter in the terminal.
/// </summary>
public static async Task PressEnterAsync(IPage page)
{
await page.Keyboard.PressAsync("Enter");
}
/// <summary>
/// Presses a specific key (Arrow keys, Escape, etc.).
/// </summary>
public static async Task PressKeyAsync(IPage page, string key)
{
await page.Keyboard.PressAsync(key);
}
/// <summary>
/// Sends a digit key press (1-9) for numbered menu selection.
/// </summary>
public static async Task PressDigitAsync(IPage page, int digit)
{
await page.Keyboard.PressAsync($"Digit{digit}");
}
/// <summary>
/// Waits for the terminal to be initialized (xterm.js loaded and terminal visible).
/// </summary>
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<bool>(@"() => {
return window.terminalInterop?.term != null;
}");
if (ready)
{
// Verify terminal has proper dimensions (should be 120x30 after init)
var cols = await page.EvaluateAsync<int>("() => 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");
}
}

View file

@ -0,0 +1,82 @@
using System.Diagnostics;
using Microsoft.Playwright;
namespace OpenTheBox.Web.Tests;
/// <summary>
/// Shared fixture that starts the Blazor WASM dev server and Playwright browser once per test collection.
/// </summary>
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<WebAppFixture>;

View file

@ -1,5 +1,5 @@
# Item Utility Report # Item Utility Report
# Total items: 152 # Total items: 151
# Total boxes: 31 # Total boxes: 31
# Total recipes: 18 # Total recipes: 18
@ -439,7 +439,7 @@
Adventure: Pirate Adventure: Pirate
Interaction: pirate_map_compass Interaction: pirate_map_compass
## Material (15 items) ## Material (14 items)
──────────────────────────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────────────────────────────
[****] material_bronze_ingot (Uncommon) — Bronze [****] material_bronze_ingot (Uncommon) — Bronze
Loot: box_ok_tier Loot: box_ok_tier
@ -502,8 +502,6 @@
[*] material_diamond_gem (Legendary) — Diamant [*] material_diamond_gem (Legendary) — Diamant
Loot: box_black Loot: box_black
[NO USE] material_wood_nail (Common) — Bois
## Meta (33 items) ## Meta (33 items)
──────────────────────────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────────────────────────────
[**] meta_colors (Rare) — Couleurs de texte [**] meta_colors (Rare) — Couleurs de texte
@ -675,4 +673,4 @@
## Orphan Items (no usage context) ## Orphan Items (no usage context)
──────────────────────────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────────────────────────────
material_wood_nail (Material, Common) (none)