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:
parent
ea487cd332
commit
2c43e31605
16 changed files with 903 additions and 36 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -43,4 +43,7 @@ Thumbs.db
|
||||||
## Claude
|
## Claude
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
builds
|
builds
|
||||||
|
|
||||||
|
## Web project: content files copied by build target (source of truth is content/)
|
||||||
|
src/OpenTheBox.Web/wwwroot/content/
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
1
src/OpenTheBox.Web/wwwroot/appsettings.json
Normal file
1
src/OpenTheBox.Web/wwwroot/appsettings.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
||||||
|
|
|
||||||
393
tests/OpenTheBox.Web.Tests/GameFlowTests.cs
Normal file
393
tests/OpenTheBox.Web.Tests/GameFlowTests.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
tests/OpenTheBox.Web.Tests/OpenTheBox.Web.Tests.csproj
Normal file
21
tests/OpenTheBox.Web.Tests/OpenTheBox.Web.Tests.csproj
Normal 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>
|
||||||
107
tests/OpenTheBox.Web.Tests/TerminalHelper.cs
Normal file
107
tests/OpenTheBox.Web.Tests/TerminalHelper.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
82
tests/OpenTheBox.Web.Tests/WebAppFixture.cs
Normal file
82
tests/OpenTheBox.Web.Tests/WebAppFixture.cs
Normal 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>;
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue