diff --git a/docs/superpowers/plans/2026-07-17-03-backend.md b/docs/superpowers/plans/2026-07-17-03-backend.md new file mode 100644 index 0000000..a7e0afa --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-03-backend.md @@ -0,0 +1,1095 @@ +# ASP.NET Backend Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the ASP.NET Core Web API that sits between the React frontend and the Game CLI. Per connected browser: accept a WebSocket at `/ws/game`, spawn one `GameCli` subprocess, run a 50 Hz tick loop that asks the trained PPO policy (loaded from ONNX) for an action and steps the CLI, and forward each new state to the browser. Reference spec at `docs/superpowers/specs/2026-07-17-cursor-following-lander-design.md` §3. + +**Architecture:** One `Backend/` .NET 8 web project. Each WebSocket connection owns a `GameSession` that owns a `GameProcess` (CLI subprocess). A single `PolicyRunner` (registered as a singleton) holds the loaded ONNX inference session and is called by all sessions. No SignalR — raw WebSocket at `/ws/game`. The frontend build will eventually be served as static files from the same host; that wiring is out of scope for this plan. + +**Tech Stack:** .NET 8 SDK, ASP.NET Core, `Microsoft.ML.OnnxRuntime`, `System.Threading.Channels`, xUnit for tests. + +--- + +## Prerequisites + +- **Plan 1 complete.** The `GameCli` binary must be publishable at `publish/GameCli/GameCli`. +- **Plan 2 complete.** A trained model must exist at `models/ppo_lander.onnx` (even a smoke-quality one is fine — the backend just needs a valid ONNX file to load). + +--- + +## File structure + +``` +Experiment_ReinforcementLearning/ +├── Backend/ +│ ├── Backend.csproj +│ ├── Program.cs # startup + WebSocket routing +│ ├── LanderConfig.cs # strongly-typed config (paths) +│ ├── GameProcess.cs # CLI subprocess wrapper +│ ├── PolicyRunner.cs # ONNX inference wrapper (singleton) +│ ├── GameSession.cs # per-connection tick loop +│ ├── WireMessages.cs # WebSocket JSON DTOs (client↔server) +│ └── appsettings.json # default Lander config +└── Backend.Tests/ + ├── Backend.Tests.csproj + ├── GameProcessTests.cs + ├── PolicyRunnerTests.cs + └── EndToEndSmokeTests.cs +``` + +`Program.cs` is the only file that touches ASP.NET plumbing. `GameProcess`/`PolicyRunner`/`GameSession` are testable in isolation. + +--- + +## Configuration convention + +Both the CLI and ONNX paths are resolved at startup relative to a "repo root" anchor computed by walking up from `AppContext.BaseDirectory` until we find `GameCli.sln`. This keeps `dotnet run --project Backend` working from any CWD. + +- `Lander:CliPath` (default: `publish/GameCli/GameCli` — relative to repo root) +- `Lander:ModelPath` (default: `models/ppo_lander.onnx` — relative to repo root) +- Env vars `LANDER__CliPath` and `LANDER__ModelPath` override (ASP.NET double-underscore convention; case-insensitive so `LANDER__CLIPATH` also works). + +--- + +## Task 1: Scaffold Backend project + +**Files:** +- Create: `Backend/Backend.csproj` +- Create: `Backend/Program.cs` (stub — replaced in Task 6) +- Create: `Backend/appsettings.json` +- Create: `Backend.Tests/Backend.Tests.csproj` +- Modify: `GameCli.sln` (add both projects) + +- [ ] **Step 1: Create the projects and add to solution** + +Run from the repo root: + +```bash +dotnet new webapi -n Backend -o Backend -f net8.0 --no-https --use-controllers false +dotnet new xunit -n Backend.Tests -o Backend.Tests -f net8.0 +dotnet sln add Backend/Backend.csproj Backend.Tests/Backend.Tests.csproj +dotnet add Backend.Tests/Backend.Tests.csproj reference Backend/Backend.csproj +dotnet add Backend/Backend.csproj package Microsoft.ML.OnnxRuntime --version 1.19.* +dotnet add Backend.Tests/Backend.Tests.csproj package Microsoft.AspNetCore.Mvc.Testing --version 8.0.* +``` + +- [ ] **Step 2: Delete boilerplate** + +The webapi template creates `Backend/WeatherForecast.cs` (or similar sample) and puts a full sample in `Program.cs`. Delete or replace: + +```bash +rm -f Backend/WeatherForecast.cs +rm -f Backend.Tests/UnitTest1.cs +``` + +Overwrite `Backend/Program.cs` with a stub (Task 6 rewrites it): + +```csharp +// Placeholder. Rewritten in Task 6. +var builder = WebApplication.CreateBuilder(args); +var app = builder.Build(); +app.MapGet("/", () => "GameCli Backend placeholder"); +app.Run(); +``` + +- [ ] **Step 3: Replace `Backend/appsettings.json`** + +Overwrite with: + +```json +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Lander": { + "CliPath": "publish/GameCli/GameCli", + "ModelPath": "models/ppo_lander.onnx" + } +} +``` + +Delete `Backend/appsettings.Development.json` if it contains sample logging that duplicates the above. + +- [ ] **Step 4: Verify it builds and starts** + +```bash +dotnet build +``` + +Expected: 0 errors. + +Quick manual check (optional but recommended): + +```bash +dotnet run --project Backend --urls http://localhost:5100 & +BACKEND_PID=$! +sleep 3 +curl -sf http://localhost:5100/ && echo +kill $BACKEND_PID +wait $BACKEND_PID 2>/dev/null +``` + +Expected: `GameCli Backend placeholder`. + +- [ ] **Step 5: Verify empty test suite runs** + +```bash +dotnet test --filter FullyQualifiedName~Backend.Tests +``` + +Expected: exit code 0 (either "Passed: 0" or "No test is available" — both fine). + +- [ ] **Step 6: Commit** + +```bash +git add Backend/ Backend.Tests/ GameCli.sln +git -c user.email=meelstorm@gmail.com -c user.name=meelstorm commit -m "feat(backend): scaffold ASP.NET Core Web API + xunit test project" +``` + +--- + +## Task 2: `WireMessages.cs` — WebSocket JSON DTOs + +**Files:** +- Create: `Backend/WireMessages.cs` + +The backend talks JSON with the browser. Same source-gen pattern as GameCli's Protocol.cs. + +- [ ] **Step 1: Write `Backend/WireMessages.cs`** + +```csharp +using System.Text.Json.Serialization; + +namespace Backend; + +// -------- Client → Server -------- + +/// Cursor position update from the browser. +public sealed class CursorMessage +{ + [JsonPropertyName("type")] public string Type { get; set; } = "cursor"; + [JsonPropertyName("x")] public float X { get; set; } + [JsonPropertyName("y")] public float Y { get; set; } +} + +// -------- Server → Client -------- + +/// Sent once on WebSocket open. Tells the client the world dimensions. +public sealed class InitFrame +{ + [JsonPropertyName("type")] public string Type { get; set; } = "init"; + [JsonPropertyName("world")] public float[] World { get; set; } = new[] { 1f, 1f }; +} + +/// Sent every physics tick with the current ship pose and target. +public sealed class StateFrame +{ + [JsonPropertyName("type")] public string Type { get; set; } = "state"; + [JsonPropertyName("x")] public float X { get; set; } + [JsonPropertyName("y")] public float Y { get; set; } + [JsonPropertyName("angle")] public float Angle { get; set; } + [JsonPropertyName("engine")] public int Engine { get; set; } + [JsonPropertyName("target")] public float[] Target { get; set; } = new[] { 0.5f, 0.5f }; + [JsonPropertyName("step")] public int Step { get; set; } +} + +/// Source-gen JSON contracts. +[JsonSourceGenerationOptions(WriteIndented = false)] +[JsonSerializable(typeof(CursorMessage))] +[JsonSerializable(typeof(InitFrame))] +[JsonSerializable(typeof(StateFrame))] +internal partial class WireJsonContext : System.Text.Json.Serialization.JsonSerializerContext +{ +} +``` + +- [ ] **Step 2: Add `InternalsVisibleTo` for tests** + +Edit `Backend/Backend.csproj`, add before ``: + +```xml + + + +``` + +- [ ] **Step 3: Verify build** + +```bash +dotnet build +``` + +Expected: 0 errors. + +- [ ] **Step 4: Commit** + +```bash +git add Backend/WireMessages.cs Backend/Backend.csproj +git -c user.email=meelstorm@gmail.com -c user.name=meelstorm commit -m "feat(backend): add WebSocket wire message DTOs" +``` + +--- + +## Task 3: `GameProcess` — CLI subprocess wrapper + +**Files:** +- Create: `Backend/GameProcess.cs` +- Create: `Backend.Tests/GameProcessTests.cs` + +`GameProcess` wraps `System.Diagnostics.Process` running the `GameCli` binary. It reads the init handshake once on start, exposes async `StepAsync` and `ResetAsync`, and disposes cleanly. It reuses the DTOs from the CLI's `Protocol.cs` — but those are `internal` to the `GameCli` assembly. Rather than take a project reference to the whole CLI (its `Program.cs` runs on load-only under top-level statements), we declare local DTOs in `GameProcess.cs` that mirror the wire format. + +- [ ] **Step 1: Write the failing tests** + +Create `Backend.Tests/GameProcessTests.cs`: + +```csharp +using System.IO; +using Backend; +using Xunit; + +namespace Backend.Tests; + +public class GameProcessTests +{ + private static string LocateCliBinary() + { + // Walk up from the test assembly's location to find publish/GameCli/GameCli. + var dir = AppContext.BaseDirectory; + while (dir is not null) + { + var candidate = Path.Combine(dir, "publish", "GameCli", "GameCli"); + if (File.Exists(candidate)) return candidate; + var parent = Directory.GetParent(dir); + if (parent is null) break; + dir = parent.FullName; + } + throw new FileNotFoundException( + "publish/GameCli/GameCli not found — run `dotnet publish GameCli -c Release -o publish/GameCli`"); + } + + [Fact] + public async Task StartsAndReadsInitHandshake() + { + await using var proc = new GameProcess(LocateCliBinary()); + await proc.StartAsync(); + Assert.Equal(0.02f, proc.Dt); + Assert.Equal(7, proc.ObsDim); + Assert.Equal(4, proc.NActions); + } + + [Fact] + public async Task StepReturnsSevenDimObservation() + { + await using var proc = new GameProcess(LocateCliBinary()); + await proc.StartAsync(); + var obs = await proc.StepAsync(action: 0, target: (0.5f, 0.5f)); + Assert.Equal(7, obs.Observation.Length); + Assert.Equal(1, obs.Step); + } + + [Fact] + public async Task ResetClearsStepCounter() + { + await using var proc = new GameProcess(LocateCliBinary()); + await proc.StartAsync(); + for (int i = 0; i < 3; i++) + await proc.StepAsync(action: 0, target: (0.5f, 0.5f)); + var afterReset = await proc.ResetAsync(seed: 42); + Assert.Equal(0, afterReset.Step); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +dotnet publish GameCli/GameCli.csproj -c Release -o publish/GameCli +dotnet test --filter FullyQualifiedName~GameProcessTests +``` + +Expected: build error — `GameProcess` does not exist. + +- [ ] **Step 3: Write `Backend/GameProcess.cs`** + +```csharp +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Backend; + +/// +/// One instance == one GameCli subprocess. Not thread-safe. +/// Callers must sequence and . +/// +public sealed class GameProcess : IAsyncDisposable +{ + private readonly string _binaryPath; + private Process? _proc; + private StreamReader? _stdout; + private StreamWriter? _stdin; + + public float Dt { get; private set; } + public int ObsDim { get; private set; } + public int NActions { get; private set; } + + public GameProcess(string binaryPath) + { + _binaryPath = binaryPath; + } + + public async Task StartAsync() + { + if (_proc is not null) throw new InvalidOperationException("already started"); + if (!File.Exists(_binaryPath)) + throw new FileNotFoundException($"GameCli binary not found at {_binaryPath}"); + + var psi = new ProcessStartInfo + { + FileName = _binaryPath, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + _proc = Process.Start(psi) + ?? throw new InvalidOperationException("failed to start GameCli"); + _stdout = _proc.StandardOutput; + _stdin = _proc.StandardInput; + + var line = await _stdout.ReadLineAsync() + ?? throw new IOException("GameCli died before init handshake"); + var init = JsonSerializer.Deserialize(line, ProcessJsonContext.Default.InitEnvelope) + ?? throw new IOException($"malformed init handshake: {line}"); + Dt = init.Init.Dt; + ObsDim = init.Init.ObsDim; + NActions = init.Init.NActions; + } + + public async Task StepAsync(int action, (float X, float Y) target) + { + var input = new StepIn + { + Action = action, + Target = new[] { target.X, target.Y }, + }; + await WriteAsync(input, ProcessJsonContext.Default.StepIn); + return await ReadStepResultAsync(); + } + + public async Task ResetAsync(int? seed = null) + { + var input = new StepIn { Cmd = "reset", Seed = seed }; + await WriteAsync(input, ProcessJsonContext.Default.StepIn); + return await ReadStepResultAsync(); + } + + private async Task WriteAsync(T value, System.Text.Json.Serialization.Metadata.JsonTypeInfo ctx) + { + if (_stdin is null) throw new InvalidOperationException("not started"); + var json = JsonSerializer.Serialize(value, ctx); + await _stdin.WriteLineAsync(json); + await _stdin.FlushAsync(); + } + + private async Task ReadStepResultAsync() + { + if (_stdout is null) throw new InvalidOperationException("not started"); + var line = await _stdout.ReadLineAsync() + ?? throw new IOException("GameCli closed stdout unexpectedly"); + var msg = JsonSerializer.Deserialize(line, ProcessJsonContext.Default.StepOut) + ?? throw new IOException($"malformed step output: {line}"); + return new StepResult(msg.Obs, msg.State, msg.Reward, msg.Done, msg.Step); + } + + public async ValueTask DisposeAsync() + { + try { _stdin?.Close(); } catch { } + if (_proc is not null) + { + try + { + var exited = _proc.WaitForExit(2000); + if (!exited) _proc.Kill(entireProcessTree: true); + } + catch { } + _proc.Dispose(); + _proc = null; + } + await ValueTask.CompletedTask; + } +} + +// -------- Result -------- + +public readonly record struct StepResult( + float[] Observation, + ShipStatePayload State, + float Reward, + bool Done, + int Step); + +// -------- Wire DTOs (local mirror of GameCli's Protocol.cs) -------- + +public sealed class StepIn +{ + [JsonPropertyName("action")] public int? Action { get; set; } + [JsonPropertyName("target")] public float[]? Target { get; set; } + [JsonPropertyName("cmd")] public string? Cmd { get; set; } + [JsonPropertyName("seed")] public int? Seed { get; set; } +} + +public sealed class StepOut +{ + [JsonPropertyName("obs")] public float[] Obs { get; set; } = Array.Empty(); + [JsonPropertyName("state")] public ShipStatePayload State { get; set; } = new(); + [JsonPropertyName("reward")] public float Reward { get; set; } + [JsonPropertyName("done")] public bool Done { get; set; } + [JsonPropertyName("step")] public int Step { get; set; } +} + +public sealed class ShipStatePayload +{ + [JsonPropertyName("x")] public float X { get; set; } + [JsonPropertyName("y")] public float Y { get; set; } + [JsonPropertyName("angle")] public float Angle { get; set; } + [JsonPropertyName("engine")] public int Engine { get; set; } +} + +public sealed class InitEnvelope +{ + [JsonPropertyName("init")] public InitPayload Init { get; set; } = new(); +} + +public sealed class InitPayload +{ + [JsonPropertyName("world")] public float[] World { get; set; } = new[] { 1f, 1f }; + [JsonPropertyName("dt")] public float Dt { get; set; } + [JsonPropertyName("obs_dim")] public int ObsDim { get; set; } + [JsonPropertyName("n_actions")] public int NActions { get; set; } +} + +[JsonSourceGenerationOptions(WriteIndented = false)] +[JsonSerializable(typeof(StepIn))] +[JsonSerializable(typeof(StepOut))] +[JsonSerializable(typeof(ShipStatePayload))] +[JsonSerializable(typeof(InitEnvelope))] +[JsonSerializable(typeof(InitPayload))] +internal partial class ProcessJsonContext : JsonSerializerContext +{ +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +dotnet test --filter FullyQualifiedName~GameProcessTests +``` + +Expected: 3 passed, 0 failed. + +- [ ] **Step 5: Commit** + +```bash +git add Backend/GameProcess.cs Backend.Tests/GameProcessTests.cs +git -c user.email=meelstorm@gmail.com -c user.name=meelstorm commit -m "feat(backend): GameProcess subprocess wrapper with async step/reset" +``` + +--- + +## Task 4: `PolicyRunner` — ONNX inference wrapper + +**Files:** +- Create: `Backend/PolicyRunner.cs` +- Create: `Backend.Tests/PolicyRunnerTests.cs` + +- [ ] **Step 1: Write the failing tests** + +Create `Backend.Tests/PolicyRunnerTests.cs`: + +```csharp +using System.IO; +using Backend; +using Xunit; + +namespace Backend.Tests; + +public class PolicyRunnerTests +{ + private static string LocateOnnxModel() + { + var dir = AppContext.BaseDirectory; + while (dir is not null) + { + var candidate = Path.Combine(dir, "models", "ppo_lander.onnx"); + if (File.Exists(candidate)) return candidate; + var parent = Directory.GetParent(dir); + if (parent is null) break; + dir = parent.FullName; + } + throw new FileNotFoundException( + "models/ppo_lander.onnx not found — run `python Training/export_onnx.py ...`"); + } + + [Fact] + public void LoadsWithoutError() + { + using var runner = new PolicyRunner(LocateOnnxModel()); + } + + [Fact] + public void SelectAction_ReturnsValidActionForZeroObs() + { + using var runner = new PolicyRunner(LocateOnnxModel()); + var obs = new float[7]; // all zeros + int action = runner.SelectAction(obs); + Assert.InRange(action, 0, 3); + } + + [Fact] + public void SelectAction_IsDeterministicForSameInput() + { + using var runner = new PolicyRunner(LocateOnnxModel()); + var obs = new float[] { 0.1f, -0.2f, 0.05f, 0.0f, 0.0f, 1.0f, 0.0f }; + int a1 = runner.SelectAction(obs); + int a2 = runner.SelectAction(obs); + Assert.Equal(a1, a2); + } + + [Fact] + public void SelectAction_ThrowsIfObsLengthWrong() + { + using var runner = new PolicyRunner(LocateOnnxModel()); + Assert.Throws(() => runner.SelectAction(new float[3])); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +dotnet test --filter FullyQualifiedName~PolicyRunnerTests +``` + +Expected: build error — `PolicyRunner` does not exist. + +- [ ] **Step 3: Write `Backend/PolicyRunner.cs`** + +```csharp +using Microsoft.ML.OnnxRuntime; +using Microsoft.ML.OnnxRuntime.Tensors; + +namespace Backend; + +/// +/// Loads a PPO policy ONNX model once and provides deterministic action selection. +/// Thread-safe: is safe for concurrent Run() calls. +/// +public sealed class PolicyRunner : IDisposable +{ + public const int ObsDim = 7; + public const int NActions = 4; + + private readonly InferenceSession _session; + private readonly string _inputName; + + public PolicyRunner(string onnxPath) + { + if (!File.Exists(onnxPath)) + throw new FileNotFoundException($"ONNX model not found at {onnxPath}"); + _session = new InferenceSession(onnxPath); + _inputName = _session.InputMetadata.Keys.First(); + } + + /// Run one inference and return the argmax action index. + public int SelectAction(ReadOnlySpan obs) + { + if (obs.Length != ObsDim) + throw new ArgumentException($"expected obs of length {ObsDim}, got {obs.Length}", nameof(obs)); + + var tensor = new DenseTensor(new[] { 1, ObsDim }); + for (int i = 0; i < ObsDim; i++) tensor[0, i] = obs[i]; + + using var results = _session.Run(new[] + { + NamedOnnxValue.CreateFromTensor(_inputName, tensor) + }); + + var logits = results.First().AsEnumerable().ToArray(); + // argmax + int best = 0; + float bestVal = logits[0]; + for (int i = 1; i < logits.Length; i++) + { + if (logits[i] > bestVal) { best = i; bestVal = logits[i]; } + } + return best; + } + + public void Dispose() => _session.Dispose(); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +dotnet test --filter FullyQualifiedName~PolicyRunnerTests +``` + +Expected: 4 passed, 0 failed. + +- [ ] **Step 5: Commit** + +```bash +git add Backend/PolicyRunner.cs Backend.Tests/PolicyRunnerTests.cs +git -c user.email=meelstorm@gmail.com -c user.name=meelstorm commit -m "feat(backend): PolicyRunner ONNX inference wrapper" +``` + +--- + +## Task 5: `GameSession` — per-connection tick loop + +**Files:** +- Create: `Backend/GameSession.cs` + +`GameSession` owns one WebSocket + one `GameProcess`. It runs two concurrent loops (reader + ticker) both driven by a single `CancellationToken`. The ticker fires at 50 Hz with a `PeriodicTimer`. + +- [ ] **Step 1: Write `Backend/GameSession.cs`** + +```csharp +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using System.Threading.Channels; + +namespace Backend; + +/// +/// One per connected browser. Runs the physics tick loop +/// and the WebSocket reader loop concurrently until either side disconnects. +/// +public sealed class GameSession : IAsyncDisposable +{ + private readonly WebSocket _socket; + private readonly GameProcess _game; + private readonly PolicyRunner _policy; + private readonly ILogger _logger; + + // Mailbox: cursor updates from the browser. Bounded to 1 with drop-newest + // isn't quite right — we want drop-oldest so the ticker always reads the + // latest cursor. Channels' DropWrite behavior keeps the first, so we do + // it manually by draining the reader on each tick. + private readonly Channel<(float X, float Y)> _cursorInbox = + Channel.CreateUnbounded<(float, float)>(); + + private (float X, float Y) _currentCursor = (0.5f, 0.5f); + private float[] _lastObs = new float[PolicyRunner.ObsDim]; + + public GameSession(WebSocket socket, GameProcess game, PolicyRunner policy, + ILogger logger) + { + _socket = socket; + _game = game; + _policy = policy; + _logger = logger; + } + + public async Task RunAsync(CancellationToken cancellation) + { + await _game.StartAsync(); + + // First step (noop) to get an initial observation for the policy. + var initial = await _game.ResetAsync(); + _lastObs = initial.Observation; + + // Send init frame to the browser. + await SendJsonAsync(new InitFrame { World = new[] { 1f, 1f } }, + WireJsonContext.Default.InitFrame, cancellation); + + var readerTask = ReaderLoopAsync(cancellation); + var tickerTask = TickerLoopAsync(cancellation); + + // First loop to complete cancels the other. + var done = await Task.WhenAny(readerTask, tickerTask); + try { await done; } + catch (OperationCanceledException) { /* expected */ } + catch (Exception ex) { _logger.LogWarning(ex, "session loop ended"); } + } + + private async Task ReaderLoopAsync(CancellationToken cancellation) + { + var buffer = new byte[4 * 1024]; + while (!cancellation.IsCancellationRequested) + { + var result = await _socket.ReceiveAsync(buffer, cancellation); + if (result.MessageType == WebSocketMessageType.Close) break; + if (result.MessageType != WebSocketMessageType.Text) continue; + + var text = Encoding.UTF8.GetString(buffer, 0, result.Count); + CursorMessage? msg; + try + { + msg = JsonSerializer.Deserialize(text, WireJsonContext.Default.CursorMessage); + } + catch (JsonException) { continue; } + if (msg is null || msg.Type != "cursor") continue; + + await _cursorInbox.Writer.WriteAsync( + (Clamp01(msg.X), Clamp01(msg.Y)), cancellation); + } + } + + private async Task TickerLoopAsync(CancellationToken cancellation) + { + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(20)); // 50 Hz + while (await timer.WaitForNextTickAsync(cancellation)) + { + // Drain the cursor mailbox — keep only the latest update. + while (_cursorInbox.Reader.TryRead(out var next)) _currentCursor = next; + + int action = _policy.SelectAction(_lastObs); + var step = await _game.StepAsync(action, _currentCursor); + _lastObs = step.Observation; + + var frame = new StateFrame + { + X = step.State.X, + Y = step.State.Y, + Angle = step.State.Angle, + Engine = step.State.Engine, + Target = new[] { _currentCursor.X, _currentCursor.Y }, + Step = step.Step, + }; + await SendJsonAsync(frame, WireJsonContext.Default.StateFrame, cancellation); + } + } + + private async Task SendJsonAsync( + T value, + System.Text.Json.Serialization.Metadata.JsonTypeInfo ctx, + CancellationToken cancellation) + { + var bytes = JsonSerializer.SerializeToUtf8Bytes(value, ctx); + await _socket.SendAsync(bytes, WebSocketMessageType.Text, + endOfMessage: true, cancellation); + } + + private static float Clamp01(float v) => v < 0f ? 0f : (v > 1f ? 1f : v); + + public async ValueTask DisposeAsync() + { + try + { + if (_socket.State == WebSocketState.Open) + await _socket.CloseAsync(WebSocketCloseStatus.NormalClosure, + "session-end", CancellationToken.None); + } + catch { } + await _game.DisposeAsync(); + } +} +``` + +- [ ] **Step 2: Verify build** + +```bash +dotnet build +``` + +Expected: 0 errors. If warnings appear about nullable-analysis on the `msg.Type` check, ignore them. + +- [ ] **Step 3: Commit** + +```bash +git add Backend/GameSession.cs +git -c user.email=meelstorm@gmail.com -c user.name=meelstorm commit -m "feat(backend): GameSession runs reader + ticker loops per WebSocket connection" +``` + +--- + +## Task 6: `Program.cs` — startup wiring + +**Files:** +- Modify: `Backend/Program.cs` (overwrite the Task 1 stub) +- Create: `Backend/LanderConfig.cs` + +- [ ] **Step 1: Write `Backend/LanderConfig.cs`** + +```csharp +namespace Backend; + +public sealed class LanderConfig +{ + public string CliPath { get; set; } = "publish/GameCli/GameCli"; + public string ModelPath { get; set; } = "models/ppo_lander.onnx"; +} + +/// +/// Walks up from to find the repo root +/// (marker file: GameCli.sln) and resolves the configured paths against it. +/// +public static class PathResolver +{ + public static string RepoRoot() + { + var dir = AppContext.BaseDirectory; + while (dir is not null) + { + if (File.Exists(Path.Combine(dir, "GameCli.sln"))) return dir; + var parent = Directory.GetParent(dir); + if (parent is null) break; + dir = parent.FullName; + } + throw new InvalidOperationException( + "could not locate repo root (no GameCli.sln found in any ancestor)"); + } + + public static string Resolve(string relativeOrAbsolute) + { + if (Path.IsPathRooted(relativeOrAbsolute)) return relativeOrAbsolute; + return Path.GetFullPath(Path.Combine(RepoRoot(), relativeOrAbsolute)); + } +} +``` + +- [ ] **Step 2: Overwrite `Backend/Program.cs`** + +```csharp +using System.Net.WebSockets; +using Backend; + +var builder = WebApplication.CreateBuilder(args); + +// -------- Config -------- +builder.Services.Configure(builder.Configuration.GetSection("Lander")); + +// -------- Singletons -------- +builder.Services.AddSingleton(sp => +{ + var cfg = sp.GetRequiredService>().Value; + var absolute = PathResolver.Resolve(cfg.ModelPath); + return new PolicyRunner(absolute); +}); + +builder.Services.AddLogging(); + +var app = builder.Build(); +app.UseWebSockets(); + +// -------- Health -------- +app.MapGet("/", () => "GameCli Backend"); + +// -------- WebSocket endpoint -------- +app.Map("/ws/game", async (HttpContext ctx, + PolicyRunner policy, + Microsoft.Extensions.Options.IOptions cfg, + ILoggerFactory loggerFactory) => +{ + if (!ctx.WebSockets.IsWebSocketRequest) + { + ctx.Response.StatusCode = 400; + return; + } + using var socket = await ctx.WebSockets.AcceptWebSocketAsync(); + var cliPath = PathResolver.Resolve(cfg.Value.CliPath); + var proc = new GameProcess(cliPath); + var logger = loggerFactory.CreateLogger(); + await using var session = new GameSession(socket, proc, policy, logger); + try + { + await session.RunAsync(ctx.RequestAborted); + } + catch (WebSocketException) { /* client disconnected */ } + catch (OperationCanceledException) { /* shutdown */ } +}); + +app.Run(); + +// -------- Public program class so Backend.Tests can use WebApplicationFactory -------- +public partial class Program { } +``` + +- [ ] **Step 3: Verify it builds and starts** + +```bash +dotnet build +dotnet run --project Backend --urls http://localhost:5100 & +BACKEND_PID=$! +sleep 3 +curl -sf http://localhost:5100/ && echo +kill $BACKEND_PID +wait $BACKEND_PID 2>/dev/null +``` + +Expected: +- Build succeeds. +- The GET returns `GameCli Backend`. +- Backend logs on startup that it loaded `models/ppo_lander.onnx` (visible in the console output before curl). + +- [ ] **Step 4: Commit** + +```bash +git add Backend/Program.cs Backend/LanderConfig.cs +git -c user.email=meelstorm@gmail.com -c user.name=meelstorm commit -m "feat(backend): wire ASP.NET startup with WebSocket endpoint and DI" +``` + +--- + +## Task 7: End-to-end smoke test — WebSocket round-trip + +**Files:** +- Create: `Backend.Tests/EndToEndSmokeTests.cs` + +Uses `WebApplicationFactory` + `ClientWebSocket` to test the whole stack: +1. Start the backend in-process. +2. Open a WebSocket to `/ws/game`. +3. Receive `init` frame. +4. Send a cursor update. +5. Receive a `state` frame within a reasonable timeout. +6. Verify shape. + +- [ ] **Step 1: Write the failing test** + +Create `Backend.Tests/EndToEndSmokeTests.cs`: + +```csharp +using System.IO; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using Backend; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace Backend.Tests; + +public class EndToEndSmokeTests : IClassFixture> +{ + private readonly WebApplicationFactory _factory; + + public EndToEndSmokeTests(WebApplicationFactory factory) + { + _factory = factory.WithWebHostBuilder(b => + { + // Prevent MVC picking up assemblies; force env vars if needed. + b.UseEnvironment("Development"); + }); + } + + [Fact] + public async Task WebSocket_ReceivesInitAndStateFrames() + { + // Prerequisites: publish/GameCli/GameCli and models/ppo_lander.onnx must exist. + var repoRoot = FindRepoRoot(); + Assert.True(File.Exists(Path.Combine(repoRoot, "publish", "GameCli", "GameCli")), + "run `dotnet publish GameCli -c Release -o publish/GameCli` before this test"); + Assert.True(File.Exists(Path.Combine(repoRoot, "models", "ppo_lander.onnx")), + "run `python Training/export_onnx.py ...` before this test"); + + var client = _factory.Server.CreateWebSocketClient(); + var baseUri = _factory.Server.BaseAddress; + var wsUri = new UriBuilder(baseUri) { Scheme = "ws", Path = "/ws/game" }.Uri; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + using var ws = await client.ConnectAsync(wsUri, cts.Token); + + var initText = await ReceiveTextAsync(ws, cts.Token); + using var initDoc = JsonDocument.Parse(initText); + Assert.Equal("init", initDoc.RootElement.GetProperty("type").GetString()); + + // Send a cursor and receive at least one state frame. + var cursorPayload = """{"type":"cursor","x":0.3,"y":0.7}"""; + await ws.SendAsync(Encoding.UTF8.GetBytes(cursorPayload), + WebSocketMessageType.Text, true, cts.Token); + + // Read up to N frames looking for a state frame; the ticker runs at 50 Hz. + for (int i = 0; i < 100; i++) + { + var text = await ReceiveTextAsync(ws, cts.Token); + using var doc = JsonDocument.Parse(text); + if (doc.RootElement.GetProperty("type").GetString() == "state") + { + Assert.True(doc.RootElement.TryGetProperty("x", out _)); + Assert.True(doc.RootElement.TryGetProperty("y", out _)); + Assert.True(doc.RootElement.TryGetProperty("angle", out _)); + Assert.True(doc.RootElement.TryGetProperty("engine", out _)); + Assert.True(doc.RootElement.TryGetProperty("target", out _)); + Assert.True(doc.RootElement.TryGetProperty("step", out _)); + return; + } + } + Assert.Fail("did not receive a state frame in 100 messages"); + } + + private static async Task ReceiveTextAsync(WebSocket ws, CancellationToken ct) + { + var buffer = new byte[16 * 1024]; + var sb = new StringBuilder(); + WebSocketReceiveResult result; + do + { + result = await ws.ReceiveAsync(buffer, ct); + sb.Append(Encoding.UTF8.GetString(buffer, 0, result.Count)); + } while (!result.EndOfMessage); + return sb.ToString(); + } + + private static string FindRepoRoot() + { + var dir = AppContext.BaseDirectory; + while (dir is not null) + { + if (File.Exists(Path.Combine(dir, "GameCli.sln"))) return dir; + var parent = Directory.GetParent(dir); + if (parent is null) break; + dir = parent.FullName; + } + throw new InvalidOperationException("repo root not found"); + } +} +``` + +- [ ] **Step 2: Publish CLI and confirm ONNX exists** + +```bash +dotnet publish GameCli/GameCli.csproj -c Release -o publish/GameCli +ls -la models/ppo_lander.onnx +``` + +- [ ] **Step 3: Run the smoke test** + +```bash +dotnet test --filter FullyQualifiedName~EndToEndSmokeTests +``` + +Expected: 1 passed. The test should complete within ~5 seconds (init frame + ~1 tick). + +- [ ] **Step 4: Run the full Backend test suite** + +```bash +dotnet test --filter FullyQualifiedName~Backend.Tests +``` + +Expected: 3 GameProcess + 4 PolicyRunner + 1 smoke = 8 passed, 0 failed. + +- [ ] **Step 5: Commit** + +```bash +git add Backend.Tests/EndToEndSmokeTests.cs +git -c user.email=meelstorm@gmail.com -c user.name=meelstorm commit -m "test(backend): end-to-end WebSocket smoke test via WebApplicationFactory" +``` + +--- + +## Definition of done for Plan 3 + +- `dotnet test --filter FullyQualifiedName~Backend.Tests` reports 8 passed, 0 failed. +- `dotnet run --project Backend` starts, logs the ONNX model load, and responds to `GET /` with `GameCli Backend`. +- Connecting a WebSocket client to `ws://localhost:5100/ws/game` receives an `init` frame followed by 50 Hz `state` frames. +- No file exceeds ~250 lines; every source file has a single clear responsibility. + +The next plan (Plan 4) will build the React + Vite frontend that opens this WebSocket, streams the mouse cursor, and renders the ship on a canvas.