Files
Experiment_ReinforcementLea…/docs/superpowers/plans/2026-07-17-03-backend.md
2026-07-17 18:23:16 +00:00

1096 lines
35 KiB
Markdown

# 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 --------
/// <summary>Cursor position update from the browser.</summary>
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 --------
/// <summary>Sent once on WebSocket open. Tells the client the world dimensions.</summary>
public sealed class InitFrame
{
[JsonPropertyName("type")] public string Type { get; set; } = "init";
[JsonPropertyName("world")] public float[] World { get; set; } = new[] { 1f, 1f };
}
/// <summary>Sent every physics tick with the current ship pose and target.</summary>
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; }
}
/// <summary>Source-gen JSON contracts.</summary>
[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 `</Project>`:
```xml
<ItemGroup>
<InternalsVisibleTo Include="Backend.Tests" />
</ItemGroup>
```
- [ ] **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;
/// <summary>
/// One instance == one <c>GameCli</c> subprocess. Not thread-safe.
/// Callers must sequence <see cref="StepAsync"/> and <see cref="ResetAsync"/>.
/// </summary>
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<StepResult> 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<StepResult> 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>(T value, System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> 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<StepResult> 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<float>();
[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<ArgumentException>(() => 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;
/// <summary>
/// Loads a PPO policy ONNX model once and provides deterministic action selection.
/// Thread-safe: <see cref="InferenceSession"/> is safe for concurrent Run() calls.
/// </summary>
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();
}
/// <summary>Run one inference and return the argmax action index.</summary>
public int SelectAction(ReadOnlySpan<float> obs)
{
if (obs.Length != ObsDim)
throw new ArgumentException($"expected obs of length {ObsDim}, got {obs.Length}", nameof(obs));
var tensor = new DenseTensor<float>(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<float>().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;
/// <summary>
/// One <see cref="GameSession"/> per connected browser. Runs the physics tick loop
/// and the WebSocket reader loop concurrently until either side disconnects.
/// </summary>
public sealed class GameSession : IAsyncDisposable
{
private readonly WebSocket _socket;
private readonly GameProcess _game;
private readonly PolicyRunner _policy;
private readonly ILogger<GameSession> _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<GameSession> 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>(
T value,
System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> 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";
}
/// <summary>
/// Walks up from <see cref="AppContext.BaseDirectory"/> to find the repo root
/// (marker file: <c>GameCli.sln</c>) and resolves the configured paths against it.
/// </summary>
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<LanderConfig>(builder.Configuration.GetSection("Lander"));
// -------- Singletons --------
builder.Services.AddSingleton<PolicyRunner>(sp =>
{
var cfg = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<LanderConfig>>().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<LanderConfig> 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<GameSession>();
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<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public EndToEndSmokeTests(WebApplicationFactory<Program> 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<string> 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.