Compare commits
33 Commits
1e1ca8bf88
...
2ca8334c00
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ca8334c00 | ||
|
|
8018a0e678 | ||
|
|
ab5b22b857 | ||
|
|
dc35a3b1b4 | ||
|
|
c6075d88ca | ||
|
|
96468d9dde | ||
|
|
b6a0709c3e | ||
|
|
f5020726ec | ||
|
|
42e5482d97 | ||
|
|
dff43288e2 | ||
|
|
67b674777b | ||
|
|
0102f5f748 | ||
|
|
9f34314c2f | ||
|
|
2c21dcfdf9 | ||
|
|
b17a845ff7 | ||
|
|
caf0376323 | ||
|
|
bbd2c36091 | ||
|
|
7b713ba361 | ||
|
|
edf815de4f | ||
|
|
ba24c752c5 | ||
|
|
30ac7a8114 | ||
|
|
d1290442ed | ||
|
|
238ff65db4 | ||
|
|
0b22d67316 | ||
|
|
6340218f88 | ||
|
|
f5ee2049da | ||
|
|
5b35d65bd2 | ||
|
|
3bca85bcc4 | ||
|
|
34fb901fe6 | ||
|
|
f31f01d321 | ||
|
|
83c6b0f41f | ||
|
|
33b9b8e77c | ||
|
|
7d15323cb9 |
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Build outputs
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
publish/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Training artifacts
|
||||||
|
models/
|
||||||
|
checkpoints/
|
||||||
|
tensorboard/
|
||||||
|
*.onnx
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vs/
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.user
|
||||||
28
Backend.Tests/Backend.Tests.csproj
Normal file
28
Backend.Tests/Backend.Tests.csproj
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.*" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.5.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Backend\Backend.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
95
Backend.Tests/EndToEndSmokeTests.cs
Normal file
95
Backend.Tests/EndToEndSmokeTests.cs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Net.WebSockets;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Backend;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
54
Backend.Tests/GameProcessTests.cs
Normal file
54
Backend.Tests/GameProcessTests.cs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
using System.IO;
|
||||||
|
using Backend;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Backend.Tests;
|
||||||
|
|
||||||
|
public class GameProcessTests
|
||||||
|
{
|
||||||
|
private static string LocateCliBinary()
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
Backend.Tests/PolicyRunnerTests.cs
Normal file
55
Backend.Tests/PolicyRunnerTests.cs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
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]));
|
||||||
|
}
|
||||||
|
}
|
||||||
19
Backend/Backend.csproj
Normal file
19
Backend/Backend.csproj
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.29" />
|
||||||
|
<PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.19.*" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="Backend.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
6
Backend/Backend.http
Normal file
6
Backend/Backend.http
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
@Backend_HostAddress = http://localhost:5281
|
||||||
|
|
||||||
|
GET {{Backend_HostAddress}}/weatherforecast/
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
###
|
||||||
166
Backend/GameProcess.cs
Normal file
166
Backend/GameProcess.cs
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
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
|
||||||
|
{
|
||||||
|
}
|
||||||
131
Backend/GameSession.cs
Normal file
131
Backend/GameSession.cs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
34
Backend/LanderConfig.cs
Normal file
34
Backend/LanderConfig.cs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
52
Backend/PolicyRunner.cs
Normal file
52
Backend/PolicyRunner.cs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
61
Backend/Program.cs
Normal file
61
Backend/Program.cs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
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();
|
||||||
|
|
||||||
|
// Fail-fast: eagerly resolve PolicyRunner so a missing ONNX file surfaces at
|
||||||
|
// startup rather than on the first WebSocket connect.
|
||||||
|
var startupLogger = app.Services.GetRequiredService<ILoggerFactory>().CreateLogger("Startup");
|
||||||
|
var policy = app.Services.GetRequiredService<PolicyRunner>();
|
||||||
|
var modelPath = PathResolver.Resolve(
|
||||||
|
app.Services.GetRequiredService<Microsoft.Extensions.Options.IOptions<LanderConfig>>().Value.ModelPath);
|
||||||
|
startupLogger.LogInformation("Loaded PPO policy from {ModelPath}", modelPath);
|
||||||
|
|
||||||
|
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 { }
|
||||||
31
Backend/Properties/launchSettings.json
Normal file
31
Backend/Properties/launchSettings.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||||
|
"iisSettings": {
|
||||||
|
"windowsAuthentication": false,
|
||||||
|
"anonymousAuthentication": true,
|
||||||
|
"iisExpress": {
|
||||||
|
"applicationUrl": "http://localhost:7830",
|
||||||
|
"sslPort": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"launchUrl": "swagger",
|
||||||
|
"applicationUrl": "http://localhost:5281",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"IIS Express": {
|
||||||
|
"commandName": "IISExpress",
|
||||||
|
"launchBrowser": true,
|
||||||
|
"launchUrl": "swagger",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
Backend/WireMessages.cs
Normal file
43
Backend/WireMessages.cs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
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
|
||||||
|
{
|
||||||
|
}
|
||||||
13
Backend/appsettings.json
Normal file
13
Backend/appsettings.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"Lander": {
|
||||||
|
"CliPath": "publish/GameCli/GameCli",
|
||||||
|
"ModelPath": "models/ppo_lander.onnx"
|
||||||
|
}
|
||||||
|
}
|
||||||
24
Frontend/.gitignore
vendored
Normal file
24
Frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
8
Frontend/.oxlintrc.json
Normal file
8
Frontend/.oxlintrc.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
32
Frontend/README.md
Normal file
32
Frontend/README.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the Oxlint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"options": {
|
||||||
|
"typeAware": true
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||||
16
Frontend/index.html
Normal file
16
Frontend/index.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Cursor-Following Lander</title>
|
||||||
|
<style>
|
||||||
|
html, body, #root { margin: 0; padding: 0; height: 100%; overflow: hidden; background: #000; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #eee; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1365
Frontend/package-lock.json
generated
Normal file
1365
Frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
Frontend/package.json
Normal file
25
Frontend/package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.13.2",
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
|
"oxlint": "^1.71.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
34
Frontend/src/App.tsx
Normal file
34
Frontend/src/App.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { useGameSocket } from './hooks/useGameSocket';
|
||||||
|
import { LanderCanvas } from './LanderCanvas';
|
||||||
|
|
||||||
|
// Vite dev server proxies `/ws` → `ws://localhost:5100`. In prod the backend
|
||||||
|
// serves the built static files, so same-origin works there too.
|
||||||
|
function buildWsUrl(): string {
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
return `${proto}//${window.location.host}/ws/game`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const { connected, state, sendCursor } = useGameSocket(buildWsUrl());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LanderCanvas state={state} onCursor={sendCursor} />
|
||||||
|
<StatusBar connected={connected} step={state?.step ?? 0} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusBarProps { connected: boolean; step: number; }
|
||||||
|
function StatusBar({ connected, step }: StatusBarProps) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', top: 8, left: 8, padding: '4px 10px',
|
||||||
|
background: 'rgba(0,0,0,0.5)', borderRadius: 4, fontSize: 12,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}>
|
||||||
|
<span style={{ color: connected ? '#5f5' : '#f55' }}>●</span>{' '}
|
||||||
|
{connected ? 'connected' : 'disconnected'} · step {step}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
Frontend/src/LanderCanvas.tsx
Normal file
52
Frontend/src/LanderCanvas.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import type { StateFrame } from './protocol';
|
||||||
|
import { renderFrame } from './render';
|
||||||
|
import { useAnimationLoop } from './hooks/useAnimationLoop';
|
||||||
|
import { useCursorSender } from './hooks/useCursorSender';
|
||||||
|
|
||||||
|
export interface LanderCanvasProps {
|
||||||
|
state: StateFrame | null;
|
||||||
|
onCursor: (x: number, y: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-viewport canvas. Redraws every animation frame reading the latest
|
||||||
|
* `state` prop (no interpolation). Mouse movement is captured on the canvas
|
||||||
|
* and forwarded through `onCursor`.
|
||||||
|
*/
|
||||||
|
export function LanderCanvas({ state, onCursor }: LanderCanvasProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const stateRef = useRef<StateFrame | null>(state);
|
||||||
|
stateRef.current = state;
|
||||||
|
|
||||||
|
// Keep the canvas's backing store in sync with its CSS size + devicePixelRatio.
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
const dpr = window.devicePixelRatio ?? 1;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
canvas.width = Math.floor(rect.width * dpr);
|
||||||
|
canvas.height = Math.floor(rect.height * dpr);
|
||||||
|
};
|
||||||
|
resize();
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
return () => window.removeEventListener('resize', resize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useAnimationLoop(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
renderFrame(canvas, stateRef.current);
|
||||||
|
});
|
||||||
|
|
||||||
|
useCursorSender(canvasRef as React.RefObject<HTMLElement>, onCursor);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
style={{ display: 'block', width: '100vw', height: '100vh', cursor: 'crosshair' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
Frontend/src/hooks/useAnimationLoop.ts
Normal file
17
Frontend/src/hooks/useAnimationLoop.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
/** Calls `callback` once per browser frame. Runs while mounted. */
|
||||||
|
export function useAnimationLoop(callback: (now: number) => void) {
|
||||||
|
const cbRef = useRef(callback);
|
||||||
|
cbRef.current = callback;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let rafId = 0;
|
||||||
|
const loop = (now: number) => {
|
||||||
|
cbRef.current(now);
|
||||||
|
rafId = requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
rafId = requestAnimationFrame(loop);
|
||||||
|
return () => cancelAnimationFrame(rafId);
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
61
Frontend/src/hooks/useCursorSender.ts
Normal file
61
Frontend/src/hooks/useCursorSender.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a viewport point to world coords in [0,1] × [0,1] using
|
||||||
|
* letterbox mapping (preserves world aspect ratio 1:1).
|
||||||
|
*/
|
||||||
|
export function viewportToWorld(
|
||||||
|
viewportPx: { x: number; y: number },
|
||||||
|
viewportSize: { w: number; h: number },
|
||||||
|
): { x: number; y: number } {
|
||||||
|
// World is 1×1. Fit inside viewport with black bars on the wider axis.
|
||||||
|
const scale = Math.min(viewportSize.w, viewportSize.h);
|
||||||
|
const offsetX = (viewportSize.w - scale) / 2;
|
||||||
|
const offsetY = (viewportSize.h - scale) / 2;
|
||||||
|
return {
|
||||||
|
x: Math.max(0, Math.min(1, (viewportPx.x - offsetX) / scale)),
|
||||||
|
y: Math.max(0, Math.min(1, (viewportPx.y - offsetY) / scale)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEND_INTERVAL_MS = 20; // 50 Hz cap
|
||||||
|
|
||||||
|
export function useCursorSender(
|
||||||
|
targetRef: React.RefObject<HTMLElement>,
|
||||||
|
onSend: (x: number, y: number) => void,
|
||||||
|
) {
|
||||||
|
const lastCursorRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
const lastSentAtRef = useRef<number>(0);
|
||||||
|
const rafRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = targetRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const handleMove = (ev: MouseEvent) => {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const world = viewportToWorld(
|
||||||
|
{ x: ev.clientX - rect.left, y: ev.clientY - rect.top },
|
||||||
|
{ w: rect.width, h: rect.height },
|
||||||
|
);
|
||||||
|
lastCursorRef.current = world;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const c = lastCursorRef.current;
|
||||||
|
if (c && now - lastSentAtRef.current >= SEND_INTERVAL_MS) {
|
||||||
|
onSend(c.x, c.y);
|
||||||
|
lastSentAtRef.current = now;
|
||||||
|
}
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
|
||||||
|
el.addEventListener('mousemove', handleMove);
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('mousemove', handleMove);
|
||||||
|
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
|
||||||
|
};
|
||||||
|
}, [targetRef, onSend]);
|
||||||
|
}
|
||||||
72
Frontend/src/hooks/useGameSocket.ts
Normal file
72
Frontend/src/hooks/useGameSocket.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import type { InitFrame, ServerFrame, StateFrame, CursorMessage } from '../protocol';
|
||||||
|
import { isInit, isState } from '../protocol';
|
||||||
|
|
||||||
|
export interface GameSocketState {
|
||||||
|
connected: boolean;
|
||||||
|
init: InitFrame | null;
|
||||||
|
state: StateFrame | null;
|
||||||
|
sendCursor: (x: number, y: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a WebSocket to the given URL, tracks the latest init/state frames.
|
||||||
|
* Auto-reconnects with exponential backoff on close.
|
||||||
|
*/
|
||||||
|
export function useGameSocket(url: string): GameSocketState {
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [init, setInit] = useState<InitFrame | null>(null);
|
||||||
|
const [state, setState] = useState<StateFrame | null>(null);
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let closed = false;
|
||||||
|
let backoffMs = 500;
|
||||||
|
let reconnectTimer: number | null = null;
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
setConnected(true);
|
||||||
|
backoffMs = 500; // reset backoff on successful connect
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
setConnected(false);
|
||||||
|
wsRef.current = null;
|
||||||
|
if (!closed) {
|
||||||
|
reconnectTimer = window.setTimeout(open, backoffMs);
|
||||||
|
backoffMs = Math.min(backoffMs * 2, 8000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onerror = () => { /* let onclose handle it */ };
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
let frame: ServerFrame;
|
||||||
|
try {
|
||||||
|
frame = JSON.parse(ev.data) as ServerFrame;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isInit(frame)) setInit(frame);
|
||||||
|
else if (isState(frame)) setState(frame);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
open();
|
||||||
|
return () => {
|
||||||
|
closed = true;
|
||||||
|
if (reconnectTimer !== null) window.clearTimeout(reconnectTimer);
|
||||||
|
wsRef.current?.close();
|
||||||
|
};
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
const sendCursor = (x: number, y: number) => {
|
||||||
|
const ws = wsRef.current;
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
const msg: CursorMessage = { type: 'cursor', x, y };
|
||||||
|
ws.send(JSON.stringify(msg));
|
||||||
|
};
|
||||||
|
|
||||||
|
return { connected, init, state, sendCursor };
|
||||||
|
}
|
||||||
10
Frontend/src/main.tsx
Normal file
10
Frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { App } from './App';
|
||||||
|
|
||||||
|
const root = document.getElementById('root')!;
|
||||||
|
createRoot(root).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
33
Frontend/src/protocol.ts
Normal file
33
Frontend/src/protocol.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// Wire messages exchanged with the backend over WebSocket.
|
||||||
|
// Mirror of Backend/WireMessages.cs.
|
||||||
|
|
||||||
|
export interface CursorMessage {
|
||||||
|
type: 'cursor';
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InitFrame {
|
||||||
|
type: 'init';
|
||||||
|
world: [number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StateFrame {
|
||||||
|
type: 'state';
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
angle: number;
|
||||||
|
engine: number;
|
||||||
|
target: [number, number];
|
||||||
|
step: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerFrame = InitFrame | StateFrame;
|
||||||
|
|
||||||
|
export function isInit(frame: ServerFrame): frame is InitFrame {
|
||||||
|
return frame.type === 'init';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isState(frame: ServerFrame): frame is StateFrame {
|
||||||
|
return frame.type === 'state';
|
||||||
|
}
|
||||||
116
Frontend/src/render.ts
Normal file
116
Frontend/src/render.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import type { StateFrame } from './protocol';
|
||||||
|
|
||||||
|
/** Ship as a filled triangle, drawn at (x,y) rotated by angle. Size in canvas px. */
|
||||||
|
export function drawShip(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
worldX: number,
|
||||||
|
worldY: number,
|
||||||
|
angle: number,
|
||||||
|
engine: number,
|
||||||
|
scale: number,
|
||||||
|
offsetX: number,
|
||||||
|
offsetY: number,
|
||||||
|
) {
|
||||||
|
const px = offsetX + worldX * scale;
|
||||||
|
const py = offsetY + worldY * scale;
|
||||||
|
const size = scale * 0.04;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(px, py);
|
||||||
|
ctx.rotate(angle);
|
||||||
|
|
||||||
|
// Body: triangle pointing up (angle=0 → up).
|
||||||
|
ctx.fillStyle = '#eee';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, -size);
|
||||||
|
ctx.lineTo(size * 0.7, size * 0.6);
|
||||||
|
ctx.lineTo(-size * 0.7, size * 0.6);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Flame if an engine is firing.
|
||||||
|
if (engine !== 0) {
|
||||||
|
ctx.fillStyle = '#ff9d3d';
|
||||||
|
ctx.beginPath();
|
||||||
|
if (engine === 2) {
|
||||||
|
// Main engine: flame under the ship.
|
||||||
|
const flame = size * 0.9 + Math.random() * size * 0.4;
|
||||||
|
ctx.moveTo(-size * 0.4, size * 0.6);
|
||||||
|
ctx.lineTo(size * 0.4, size * 0.6);
|
||||||
|
ctx.lineTo(0, size * 0.6 + flame);
|
||||||
|
} else if (engine === 1) {
|
||||||
|
// Left thruster: flame on right side of body.
|
||||||
|
ctx.moveTo(size * 0.7, -size * 0.2);
|
||||||
|
ctx.lineTo(size * 0.7, size * 0.2);
|
||||||
|
ctx.lineTo(size * 1.3, 0);
|
||||||
|
} else if (engine === 3) {
|
||||||
|
// Right thruster: flame on left side of body.
|
||||||
|
ctx.moveTo(-size * 0.7, -size * 0.2);
|
||||||
|
ctx.lineTo(-size * 0.7, size * 0.2);
|
||||||
|
ctx.lineTo(-size * 1.3, 0);
|
||||||
|
}
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draw a crosshair at the target world position. */
|
||||||
|
export function drawTarget(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
worldX: number,
|
||||||
|
worldY: number,
|
||||||
|
scale: number,
|
||||||
|
offsetX: number,
|
||||||
|
offsetY: number,
|
||||||
|
) {
|
||||||
|
const px = offsetX + worldX * scale;
|
||||||
|
const py = offsetY + worldY * scale;
|
||||||
|
const r = 8;
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#5cf';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px, py, r, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(px - r * 1.5, py);
|
||||||
|
ctx.lineTo(px + r * 1.5, py);
|
||||||
|
ctx.moveTo(px, py - r * 1.5);
|
||||||
|
ctx.lineTo(px, py + r * 1.5);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Layout: fit the 1×1 world into the canvas with black letterbox bars. */
|
||||||
|
export function layout(canvas: HTMLCanvasElement) {
|
||||||
|
const scale = Math.min(canvas.width, canvas.height);
|
||||||
|
const offsetX = (canvas.width - scale) / 2;
|
||||||
|
const offsetY = (canvas.height - scale) / 2;
|
||||||
|
return { scale, offsetX, offsetY };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render one frame: clear + world background + target + ship. */
|
||||||
|
export function renderFrame(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
state: StateFrame | null,
|
||||||
|
) {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
// Clear
|
||||||
|
ctx.fillStyle = '#000';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
const { scale, offsetX, offsetY } = layout(canvas);
|
||||||
|
|
||||||
|
// World background (subtle dark band so the play area is visible).
|
||||||
|
ctx.fillStyle = '#0a0a12';
|
||||||
|
ctx.fillRect(offsetX, offsetY, scale, scale);
|
||||||
|
|
||||||
|
if (!state) return;
|
||||||
|
|
||||||
|
drawTarget(ctx, state.target[0], state.target[1], scale, offsetX, offsetY);
|
||||||
|
drawShip(ctx, state.x, state.y, state.angle, state.engine, scale, offsetX, offsetY);
|
||||||
|
}
|
||||||
26
Frontend/tsconfig.app.json
Normal file
26
Frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"allowArbitraryExtensions": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
Frontend/tsconfig.json
Normal file
7
Frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
23
Frontend/tsconfig.node.json
Normal file
23
Frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"module": "nodenext",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
17
Frontend/vite.config.ts
Normal file
17
Frontend/vite.config.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/ws': {
|
||||||
|
target: 'ws://localhost:5100',
|
||||||
|
ws: true,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
27
GameCli.Tests/GameCli.Tests.csproj
Normal file
27
GameCli.Tests/GameCli.Tests.csproj
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.5.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\GameCli\GameCli.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
52
GameCli.Tests/ObservationTests.cs
Normal file
52
GameCli.Tests/ObservationTests.cs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
using GameCli;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace GameCli.Tests;
|
||||||
|
|
||||||
|
public class ObservationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Build_HasSevenDimensions()
|
||||||
|
{
|
||||||
|
var ship = new ShipState(0.5f, 0.5f, 0f, 0f, 0f, 0f);
|
||||||
|
var obs = Observation.Build(ship, target: (0.5f, 0.5f));
|
||||||
|
Assert.Equal(7, obs.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_DisplacementIsTargetMinusShip()
|
||||||
|
{
|
||||||
|
var ship = new ShipState(0.3f, 0.4f, 0f, 0f, 0f, 0f);
|
||||||
|
var obs = Observation.Build(ship, target: (0.7f, 0.9f));
|
||||||
|
// dx, dy at indices 0, 1
|
||||||
|
Assert.Equal(0.4f, obs[0], precision: 5);
|
||||||
|
Assert.Equal(0.5f, obs[1], precision: 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_EncodesVelocity()
|
||||||
|
{
|
||||||
|
var ship = new ShipState(0.5f, 0.5f, 0.1f, -0.2f, 0f, 0f);
|
||||||
|
var obs = Observation.Build(ship, target: (0.5f, 0.5f));
|
||||||
|
Assert.Equal(0.1f, obs[2], precision: 5);
|
||||||
|
Assert.Equal(-0.2f, obs[3], precision: 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_EncodesAngleAsSinCos()
|
||||||
|
{
|
||||||
|
var ship = new ShipState(0.5f, 0.5f, 0f, 0f, MathF.PI / 2f, 0f);
|
||||||
|
var obs = Observation.Build(ship, target: (0.5f, 0.5f));
|
||||||
|
// sin(π/2)=1, cos(π/2)=0
|
||||||
|
Assert.Equal(1f, obs[4], precision: 5);
|
||||||
|
Assert.Equal(0f, obs[5], precision: 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_LastDimIsAngularVelocity()
|
||||||
|
{
|
||||||
|
var ship = new ShipState(0.5f, 0.5f, 0f, 0f, 0f, 0.7f);
|
||||||
|
var obs = Observation.Build(ship, target: (0.5f, 0.5f));
|
||||||
|
Assert.Equal(0.7f, obs[6], precision: 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
GameCli.Tests/PhysicsTests.cs
Normal file
64
GameCli.Tests/PhysicsTests.cs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
using GameCli;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace GameCli.Tests;
|
||||||
|
|
||||||
|
public class PhysicsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Noop_UnderGravity_ShipFallsDownward()
|
||||||
|
{
|
||||||
|
var start = new ShipState(0.5f, 0.5f, 0f, 0f, 0f, 0f);
|
||||||
|
var next = Physics.Step(start, action: 0);
|
||||||
|
Assert.True(next.VY > 0, $"expected VY > 0 (gravity down), got {next.VY}");
|
||||||
|
Assert.True(next.Y > start.Y, $"expected Y to increase (fall), got {next.Y}");
|
||||||
|
Assert.Equal(start.X, next.X, precision: 5);
|
||||||
|
Assert.Equal(0f, next.Angle);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MainEngine_WhenUpright_CancelsThenReversesGravity()
|
||||||
|
{
|
||||||
|
// Main engine thrust > gravity, so acceleration is net upward (VY becomes negative).
|
||||||
|
var start = new ShipState(0.5f, 0.5f, 0f, 0f, 0f, 0f);
|
||||||
|
var next = Physics.Step(start, action: 2);
|
||||||
|
Assert.True(next.VY < 0, $"expected VY < 0 (net upward), got {next.VY}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LeftThruster_AppliesNegativeTorque()
|
||||||
|
{
|
||||||
|
var start = new ShipState(0.5f, 0.5f, 0f, 0f, 0f, 0f);
|
||||||
|
var next = Physics.Step(start, action: 1);
|
||||||
|
Assert.True(next.AngularVelocity < 0,
|
||||||
|
$"expected angular velocity < 0, got {next.AngularVelocity}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RightThruster_AppliesPositiveTorque()
|
||||||
|
{
|
||||||
|
var start = new ShipState(0.5f, 0.5f, 0f, 0f, 0f, 0f);
|
||||||
|
var next = Physics.Step(start, action: 3);
|
||||||
|
Assert.True(next.AngularVelocity > 0,
|
||||||
|
$"expected angular velocity > 0, got {next.AngularVelocity}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MainEngine_WhenRotated90Right_ThrustsRight()
|
||||||
|
{
|
||||||
|
// Angle = +pi/2 means ship points to the +x direction (right).
|
||||||
|
// Main engine pushes along body-up, which is now world +x.
|
||||||
|
var start = new ShipState(0.5f, 0.5f, 0f, 0f, MathF.PI / 2f, 0f);
|
||||||
|
var next = Physics.Step(start, action: 2);
|
||||||
|
Assert.True(next.VX > 0, $"expected VX > 0 (thrust right), got {next.VX}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Step_IsDeterministic()
|
||||||
|
{
|
||||||
|
var start = new ShipState(0.3f, 0.4f, 0.1f, -0.05f, 0.2f, 0.3f);
|
||||||
|
var a = Physics.Step(start, action: 2);
|
||||||
|
var b = Physics.Step(start, action: 2);
|
||||||
|
Assert.Equal(a, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
139
GameCli.Tests/ProgramSmokeTests.cs
Normal file
139
GameCli.Tests/ProgramSmokeTests.cs
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text.Json;
|
||||||
|
using GameCli;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace GameCli.Tests;
|
||||||
|
|
||||||
|
public class ProgramSmokeTests
|
||||||
|
{
|
||||||
|
private static string GetProjectPath()
|
||||||
|
{
|
||||||
|
// Locate the GameCli binary relative to the test binary's output dir.
|
||||||
|
// Tests run from GameCli.Tests/bin/<Config>/net8.0/, so ../../../.. gets to repo root.
|
||||||
|
var testDir = AppContext.BaseDirectory;
|
||||||
|
var repoRoot = Path.GetFullPath(Path.Combine(testDir, "..", "..", "..", ".."));
|
||||||
|
var projectPath = Path.Combine(repoRoot, "GameCli", "GameCli.csproj");
|
||||||
|
Assert.True(File.Exists(projectPath),
|
||||||
|
$"could not find GameCli.csproj at {projectPath}");
|
||||||
|
return projectPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Process StartGameCli()
|
||||||
|
{
|
||||||
|
var projectPath = GetProjectPath();
|
||||||
|
|
||||||
|
var psi = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "dotnet",
|
||||||
|
ArgumentList = { "run", "--project", projectPath, "-c", "Release", "--no-build" },
|
||||||
|
RedirectStandardInput = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
};
|
||||||
|
var p = Process.Start(psi)
|
||||||
|
?? throw new InvalidOperationException("failed to start GameCli");
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FirstOutputLine_IsInitHandshake()
|
||||||
|
{
|
||||||
|
// Ensure release build exists so --no-build works reliably.
|
||||||
|
RunOrFail("dotnet", $"build -c Release \"{GetProjectPath()}\"");
|
||||||
|
|
||||||
|
using var p = StartGameCli();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var line = p.StandardOutput.ReadLine();
|
||||||
|
Assert.NotNull(line);
|
||||||
|
var init = JsonSerializer.Deserialize(line!, ProtocolJsonContext.Default.InitMessage);
|
||||||
|
Assert.NotNull(init?.Init);
|
||||||
|
Assert.Equal(Physics.Dt, init!.Init.Dt);
|
||||||
|
Assert.Equal(Observation.Dim, init.Init.ObsDim);
|
||||||
|
Assert.Equal(4, init.Init.NActions);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
p.StandardInput.Close();
|
||||||
|
p.WaitForExit(3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StepRoundTrip_ReturnsSevenDimObservation()
|
||||||
|
{
|
||||||
|
RunOrFail("dotnet", $"build -c Release \"{GetProjectPath()}\"");
|
||||||
|
|
||||||
|
using var p = StartGameCli();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ = p.StandardOutput.ReadLine(); // skip init
|
||||||
|
|
||||||
|
p.StandardInput.WriteLine("""{"action":0,"target":[0.5,0.5]}""");
|
||||||
|
p.StandardInput.Flush();
|
||||||
|
|
||||||
|
var line = p.StandardOutput.ReadLine();
|
||||||
|
Assert.NotNull(line);
|
||||||
|
var output = JsonSerializer.Deserialize(line!, ProtocolJsonContext.Default.StepOutput);
|
||||||
|
Assert.NotNull(output);
|
||||||
|
Assert.Equal(7, output!.Obs.Length);
|
||||||
|
Assert.Equal(1, output.Step);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
p.StandardInput.Close();
|
||||||
|
p.WaitForExit(3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResetCommand_ResetsStepCounter()
|
||||||
|
{
|
||||||
|
RunOrFail("dotnet", $"build -c Release \"{GetProjectPath()}\"");
|
||||||
|
|
||||||
|
using var p = StartGameCli();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ = p.StandardOutput.ReadLine(); // init
|
||||||
|
|
||||||
|
// Take three steps.
|
||||||
|
for (int i = 0; i < 3; i++)
|
||||||
|
{
|
||||||
|
p.StandardInput.WriteLine("""{"action":0,"target":[0.5,0.5]}""");
|
||||||
|
}
|
||||||
|
p.StandardInput.Flush();
|
||||||
|
string? last = null;
|
||||||
|
for (int i = 0; i < 3; i++) last = p.StandardOutput.ReadLine();
|
||||||
|
var afterThree = JsonSerializer.Deserialize(last!, ProtocolJsonContext.Default.StepOutput);
|
||||||
|
Assert.Equal(3, afterThree!.Step);
|
||||||
|
|
||||||
|
// Reset. Expect step=0 on the next output line.
|
||||||
|
p.StandardInput.WriteLine("""{"cmd":"reset","seed":42}""");
|
||||||
|
p.StandardInput.Flush();
|
||||||
|
var afterReset = JsonSerializer.Deserialize(
|
||||||
|
p.StandardOutput.ReadLine()!, ProtocolJsonContext.Default.StepOutput);
|
||||||
|
Assert.Equal(0, afterReset!.Step);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
p.StandardInput.Close();
|
||||||
|
p.WaitForExit(3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RunOrFail(string file, string args)
|
||||||
|
{
|
||||||
|
var psi = new ProcessStartInfo(file, args)
|
||||||
|
{
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
};
|
||||||
|
using var p = Process.Start(psi)!;
|
||||||
|
p.WaitForExit(120_000);
|
||||||
|
if (p.ExitCode != 0)
|
||||||
|
throw new Exception($"{file} {args} exited {p.ExitCode}\nstderr:\n{p.StandardError.ReadToEnd()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
81
GameCli.Tests/ProtocolTests.cs
Normal file
81
GameCli.Tests/ProtocolTests.cs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using GameCli;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace GameCli.Tests;
|
||||||
|
|
||||||
|
public class ProtocolTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void StepInput_ParsesActionAndTarget()
|
||||||
|
{
|
||||||
|
var json = """{"action":2,"target":[0.35,0.60]}""";
|
||||||
|
var input = JsonSerializer.Deserialize(json, ProtocolJsonContext.Default.StepInput);
|
||||||
|
Assert.NotNull(input);
|
||||||
|
Assert.Equal(2, input!.Action);
|
||||||
|
Assert.NotNull(input.Target);
|
||||||
|
Assert.Equal(0.35f, input.Target![0]);
|
||||||
|
Assert.Equal(0.60f, input.Target![1]);
|
||||||
|
Assert.Null(input.Cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StepInput_ParsesResetCommand()
|
||||||
|
{
|
||||||
|
var json = """{"cmd":"reset","seed":12345}""";
|
||||||
|
var input = JsonSerializer.Deserialize(json, ProtocolJsonContext.Default.StepInput);
|
||||||
|
Assert.NotNull(input);
|
||||||
|
Assert.Equal("reset", input!.Cmd);
|
||||||
|
Assert.Equal(12345, input.Seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StepInput_ParsesResetCommandWithoutSeed()
|
||||||
|
{
|
||||||
|
var json = """{"cmd":"reset"}""";
|
||||||
|
var input = JsonSerializer.Deserialize(json, ProtocolJsonContext.Default.StepInput);
|
||||||
|
Assert.NotNull(input);
|
||||||
|
Assert.Equal("reset", input!.Cmd);
|
||||||
|
Assert.Null(input.Seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StepOutput_SerializesAllFields()
|
||||||
|
{
|
||||||
|
var output = new StepOutput
|
||||||
|
{
|
||||||
|
Obs = new[] { 0.1f, 0.2f, 0.3f, 0.4f, 0.5f, 0.6f, 0.7f },
|
||||||
|
State = new ShipStateDto { X = 0.5f, Y = 0.6f, Angle = 0.1f, Engine = 2 },
|
||||||
|
Reward = -0.42f,
|
||||||
|
Done = false,
|
||||||
|
Step = 137,
|
||||||
|
};
|
||||||
|
var json = JsonSerializer.Serialize(output, ProtocolJsonContext.Default.StepOutput);
|
||||||
|
Assert.Contains("\"obs\":", json);
|
||||||
|
Assert.Contains("\"state\":", json);
|
||||||
|
Assert.Contains("\"reward\":", json);
|
||||||
|
Assert.Contains("\"done\":false", json);
|
||||||
|
Assert.Contains("\"step\":137", json);
|
||||||
|
Assert.Contains("\"engine\":2", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InitMessage_SerializesExpectedShape()
|
||||||
|
{
|
||||||
|
var init = new InitMessage
|
||||||
|
{
|
||||||
|
Init = new InitPayload
|
||||||
|
{
|
||||||
|
World = new[] { 1.0f, 1.0f },
|
||||||
|
Dt = 0.02f,
|
||||||
|
ObsDim = 7,
|
||||||
|
NActions = 4,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var json = JsonSerializer.Serialize(init, ProtocolJsonContext.Default.InitMessage);
|
||||||
|
Assert.Contains("\"world\":[1,1]", json);
|
||||||
|
Assert.Contains("\"dt\":0.02", json);
|
||||||
|
Assert.Contains("\"obs_dim\":7", json);
|
||||||
|
Assert.Contains("\"n_actions\":4", json);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
GameCli.Tests/RewardTests.cs
Normal file
71
GameCli.Tests/RewardTests.cs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
using GameCli;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace GameCli.Tests;
|
||||||
|
|
||||||
|
public class RewardTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void AtTarget_ZeroVel_ZeroAngle_Noop_HasZeroReward()
|
||||||
|
{
|
||||||
|
var ship = new ShipState(0.5f, 0.5f, 0f, 0f, 0f, 0f);
|
||||||
|
float r = Reward.Compute(ship, target: (0.5f, 0.5f), action: 0);
|
||||||
|
Assert.Equal(0f, r, precision: 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FarFromTarget_HasLargerNegativeThanNear()
|
||||||
|
{
|
||||||
|
var near = new ShipState(0.5f, 0.5f, 0f, 0f, 0f, 0f);
|
||||||
|
var far = new ShipState(0.9f, 0.9f, 0f, 0f, 0f, 0f);
|
||||||
|
float rNear = Reward.Compute(near, target: (0.5f, 0.5f), action: 0);
|
||||||
|
float rFar = Reward.Compute(far, target: (0.5f, 0.5f), action: 0);
|
||||||
|
Assert.True(rFar < rNear, $"far reward {rFar} should be < near reward {rNear}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HighVelocity_PenalizesReward()
|
||||||
|
{
|
||||||
|
var stopped = new ShipState(0.5f, 0.5f, 0f, 0f, 0f, 0f);
|
||||||
|
var moving = new ShipState(0.5f, 0.5f, 1f, 1f, 0f, 0f);
|
||||||
|
float rStop = Reward.Compute(stopped, target: (0.5f, 0.5f), action: 0);
|
||||||
|
float rMove = Reward.Compute(moving, target: (0.5f, 0.5f), action: 0);
|
||||||
|
Assert.True(rMove < rStop, $"moving reward {rMove} should be < stopped reward {rStop}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tilted_PenalizesReward()
|
||||||
|
{
|
||||||
|
var upright = new ShipState(0.5f, 0.5f, 0f, 0f, 0f, 0f);
|
||||||
|
var tilted = new ShipState(0.5f, 0.5f, 0f, 0f, 0.8f, 0f);
|
||||||
|
float rUp = Reward.Compute(upright, target: (0.5f, 0.5f), action: 0);
|
||||||
|
float rTi = Reward.Compute(tilted, target: (0.5f, 0.5f), action: 0);
|
||||||
|
Assert.True(rTi < rUp, $"tilted reward {rTi} should be < upright reward {rUp}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FiringEngine_PenalizesReward()
|
||||||
|
{
|
||||||
|
var ship = new ShipState(0.5f, 0.5f, 0f, 0f, 0f, 0f);
|
||||||
|
float rNoop = Reward.Compute(ship, target: (0.5f, 0.5f), action: 0);
|
||||||
|
float rMain = Reward.Compute(ship, target: (0.5f, 0.5f), action: 2);
|
||||||
|
float rLeft = Reward.Compute(ship, target: (0.5f, 0.5f), action: 1);
|
||||||
|
Assert.True(rMain < rNoop, "firing main should be worse than noop");
|
||||||
|
Assert.True(rLeft < rNoop, "firing left should be worse than noop");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AngleWrap_FullRevolutionsProduceSameReward()
|
||||||
|
{
|
||||||
|
// Physically identical poses (angle differs by 2π) must produce the same reward.
|
||||||
|
// Otherwise a spinning ship accumulates unbounded angle penalty.
|
||||||
|
var upright = new ShipState(0.5f, 0.5f, 0f, 0f, 0f, 0f);
|
||||||
|
var oneSpin = new ShipState(0.5f, 0.5f, 0f, 0f, 2f * MathF.PI, 0f);
|
||||||
|
var fiveSpins = new ShipState(0.5f, 0.5f, 0f, 0f, 10f * MathF.PI, 0f);
|
||||||
|
float r0 = Reward.Compute(upright, target: (0.5f, 0.5f), action: 0);
|
||||||
|
float r1 = Reward.Compute(oneSpin, target: (0.5f, 0.5f), action: 0);
|
||||||
|
float r5 = Reward.Compute(fiveSpins, target: (0.5f, 0.5f), action: 0);
|
||||||
|
Assert.Equal(r0, r1, precision: 4);
|
||||||
|
Assert.Equal(r0, r5, precision: 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
GameCli.sln
Normal file
76
GameCli.sln
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.0.31903.59
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCli", "GameCli\GameCli.csproj", "{6F988E2C-7994-4AE8-8354-85866FEA6CDE}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCli.Tests", "GameCli.Tests\GameCli.Tests.csproj", "{C0C7008D-E7BE-47E2-8CBB-2630AF817CC7}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Backend", "Backend\Backend.csproj", "{69BB68BF-2864-497E-993C-CB9D67A21041}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Backend.Tests", "Backend.Tests\Backend.Tests.csproj", "{BA5C4CD7-66EC-4512-8155-D81E48A6A568}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Debug|x64 = Debug|x64
|
||||||
|
Debug|x86 = Debug|x86
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
Release|x64 = Release|x64
|
||||||
|
Release|x86 = Release|x86
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{6F988E2C-7994-4AE8-8354-85866FEA6CDE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{6F988E2C-7994-4AE8-8354-85866FEA6CDE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{6F988E2C-7994-4AE8-8354-85866FEA6CDE}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{6F988E2C-7994-4AE8-8354-85866FEA6CDE}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{6F988E2C-7994-4AE8-8354-85866FEA6CDE}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{6F988E2C-7994-4AE8-8354-85866FEA6CDE}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{6F988E2C-7994-4AE8-8354-85866FEA6CDE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{6F988E2C-7994-4AE8-8354-85866FEA6CDE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{6F988E2C-7994-4AE8-8354-85866FEA6CDE}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{6F988E2C-7994-4AE8-8354-85866FEA6CDE}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{6F988E2C-7994-4AE8-8354-85866FEA6CDE}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{6F988E2C-7994-4AE8-8354-85866FEA6CDE}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{C0C7008D-E7BE-47E2-8CBB-2630AF817CC7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{C0C7008D-E7BE-47E2-8CBB-2630AF817CC7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{C0C7008D-E7BE-47E2-8CBB-2630AF817CC7}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{C0C7008D-E7BE-47E2-8CBB-2630AF817CC7}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{C0C7008D-E7BE-47E2-8CBB-2630AF817CC7}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{C0C7008D-E7BE-47E2-8CBB-2630AF817CC7}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{C0C7008D-E7BE-47E2-8CBB-2630AF817CC7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{C0C7008D-E7BE-47E2-8CBB-2630AF817CC7}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{C0C7008D-E7BE-47E2-8CBB-2630AF817CC7}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{C0C7008D-E7BE-47E2-8CBB-2630AF817CC7}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{C0C7008D-E7BE-47E2-8CBB-2630AF817CC7}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{C0C7008D-E7BE-47E2-8CBB-2630AF817CC7}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{69BB68BF-2864-497E-993C-CB9D67A21041}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{69BB68BF-2864-497E-993C-CB9D67A21041}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{69BB68BF-2864-497E-993C-CB9D67A21041}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{69BB68BF-2864-497E-993C-CB9D67A21041}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{69BB68BF-2864-497E-993C-CB9D67A21041}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{69BB68BF-2864-497E-993C-CB9D67A21041}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{69BB68BF-2864-497E-993C-CB9D67A21041}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{69BB68BF-2864-497E-993C-CB9D67A21041}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{69BB68BF-2864-497E-993C-CB9D67A21041}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{69BB68BF-2864-497E-993C-CB9D67A21041}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{69BB68BF-2864-497E-993C-CB9D67A21041}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{69BB68BF-2864-497E-993C-CB9D67A21041}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{BA5C4CD7-66EC-4512-8155-D81E48A6A568}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{BA5C4CD7-66EC-4512-8155-D81E48A6A568}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{BA5C4CD7-66EC-4512-8155-D81E48A6A568}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{BA5C4CD7-66EC-4512-8155-D81E48A6A568}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{BA5C4CD7-66EC-4512-8155-D81E48A6A568}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{BA5C4CD7-66EC-4512-8155-D81E48A6A568}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{BA5C4CD7-66EC-4512-8155-D81E48A6A568}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{BA5C4CD7-66EC-4512-8155-D81E48A6A568}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{BA5C4CD7-66EC-4512-8155-D81E48A6A568}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{BA5C4CD7-66EC-4512-8155-D81E48A6A568}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{BA5C4CD7-66EC-4512-8155-D81E48A6A568}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{BA5C4CD7-66EC-4512-8155-D81E48A6A568}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
14
GameCli/GameCli.csproj
Normal file
14
GameCli/GameCli.csproj
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="GameCli.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
25
GameCli/Observation.cs
Normal file
25
GameCli/Observation.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
namespace GameCli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the 7-dim observation vector fed to PPO:
|
||||||
|
/// [dx, dy, vx, vy, sin(angle), cos(angle), angular_velocity]
|
||||||
|
/// Angle is encoded as sin/cos to avoid the discontinuity at ±π.
|
||||||
|
/// </summary>
|
||||||
|
public static class Observation
|
||||||
|
{
|
||||||
|
public const int Dim = 7;
|
||||||
|
|
||||||
|
public static float[] Build(ShipState s, (float X, float Y) target)
|
||||||
|
{
|
||||||
|
return new float[Dim]
|
||||||
|
{
|
||||||
|
target.X - s.X,
|
||||||
|
target.Y - s.Y,
|
||||||
|
s.VX,
|
||||||
|
s.VY,
|
||||||
|
MathF.Sin(s.Angle),
|
||||||
|
MathF.Cos(s.Angle),
|
||||||
|
s.AngularVelocity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
78
GameCli/Physics.cs
Normal file
78
GameCli/Physics.cs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
namespace GameCli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure physics stepper. All constants are public so training/tuning can inspect them.
|
||||||
|
/// Explicit Euler integration at fixed dt.
|
||||||
|
/// </summary>
|
||||||
|
public static class Physics
|
||||||
|
{
|
||||||
|
public const float Dt = 0.02f; // 50 Hz
|
||||||
|
public const float Gravity = 0.5f; // world units / s^2, +Y (downward)
|
||||||
|
public const float MainThrust = 1.2f; // world units / s^2 along body-up
|
||||||
|
public const float SideTorque = 4.0f; // rad / s^2
|
||||||
|
public const float SideLateralImpulse = 0.15f; // world units / s^2 along body-x
|
||||||
|
public const float LinearDrag = 0.10f; // per-second
|
||||||
|
public const float AngularDrag = 0.50f; // per-second
|
||||||
|
|
||||||
|
public const int ActionNoop = 0;
|
||||||
|
public const int ActionLeft = 1;
|
||||||
|
public const int ActionMain = 2;
|
||||||
|
public const int ActionRight = 3;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Advance the ship state by one dt given a discrete action.
|
||||||
|
/// Gravity is always applied. Thrusters add to acceleration/torque.
|
||||||
|
/// </summary>
|
||||||
|
public static ShipState Step(ShipState s, int action)
|
||||||
|
{
|
||||||
|
// Start with gravity.
|
||||||
|
float ax = 0f;
|
||||||
|
float ay = Gravity;
|
||||||
|
float torque = 0f;
|
||||||
|
|
||||||
|
// Body-up direction in world coords, given angle:
|
||||||
|
// angle=0 → (0, -1) "up" on screen (Y is down)
|
||||||
|
// angle=+π/2 → (+1, 0) right
|
||||||
|
// angle=+π → (0, +1) down
|
||||||
|
float sinA = MathF.Sin(s.Angle);
|
||||||
|
float cosA = MathF.Cos(s.Angle);
|
||||||
|
float bodyUpX = sinA;
|
||||||
|
float bodyUpY = -cosA;
|
||||||
|
// Body-right direction (perpendicular, rotated +90°):
|
||||||
|
float bodyRightX = cosA;
|
||||||
|
float bodyRightY = sinA;
|
||||||
|
|
||||||
|
switch (action)
|
||||||
|
{
|
||||||
|
case ActionMain:
|
||||||
|
ax += bodyUpX * MainThrust;
|
||||||
|
ay += bodyUpY * MainThrust;
|
||||||
|
break;
|
||||||
|
case ActionLeft:
|
||||||
|
torque -= SideTorque;
|
||||||
|
ax += bodyRightX * SideLateralImpulse;
|
||||||
|
ay += bodyRightY * SideLateralImpulse;
|
||||||
|
break;
|
||||||
|
case ActionRight:
|
||||||
|
torque += SideTorque;
|
||||||
|
ax -= bodyRightX * SideLateralImpulse;
|
||||||
|
ay -= bodyRightY * SideLateralImpulse;
|
||||||
|
break;
|
||||||
|
case ActionNoop:
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Integrate velocity, apply linear drag, integrate position.
|
||||||
|
float vx = (s.VX + ax * Dt) * (1f - LinearDrag * Dt);
|
||||||
|
float vy = (s.VY + ay * Dt) * (1f - LinearDrag * Dt);
|
||||||
|
float x = s.X + vx * Dt;
|
||||||
|
float y = s.Y + vy * Dt;
|
||||||
|
|
||||||
|
// Angular: same pattern.
|
||||||
|
float w = (s.AngularVelocity + torque * Dt) * (1f - AngularDrag * Dt);
|
||||||
|
float angle = s.Angle + w * Dt;
|
||||||
|
|
||||||
|
return new ShipState(x, y, vx, vy, angle, w);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
GameCli/Program.cs
Normal file
98
GameCli/Program.cs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using GameCli;
|
||||||
|
|
||||||
|
// Deterministic PRNG for start-pose randomization on reset.
|
||||||
|
// Seeded fresh on each reset if the caller supplies a seed.
|
||||||
|
var rng = new Random();
|
||||||
|
|
||||||
|
// Current world state and step counter. Reset on {"cmd":"reset"}.
|
||||||
|
var ship = ShipState.AtCenter();
|
||||||
|
int stepCount = 0;
|
||||||
|
int lastAction = 0;
|
||||||
|
|
||||||
|
// --- Startup handshake ---------------------------------------------------
|
||||||
|
var init = new InitMessage
|
||||||
|
{
|
||||||
|
Init = new InitPayload
|
||||||
|
{
|
||||||
|
World = new[] { 1f, 1f },
|
||||||
|
Dt = Physics.Dt,
|
||||||
|
ObsDim = Observation.Dim,
|
||||||
|
NActions = 4,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Console.WriteLine(JsonSerializer.Serialize(init, ProtocolJsonContext.Default.InitMessage));
|
||||||
|
Console.Out.Flush();
|
||||||
|
|
||||||
|
// --- Read-line loop ------------------------------------------------------
|
||||||
|
string? line;
|
||||||
|
while ((line = Console.In.ReadLine()) is not null)
|
||||||
|
{
|
||||||
|
if (line.Length == 0) continue;
|
||||||
|
|
||||||
|
StepInput? input;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
input = JsonSerializer.Deserialize(line, ProtocolJsonContext.Default.StepInput);
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"invalid json: {ex.Message}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (input is null) continue;
|
||||||
|
|
||||||
|
// Reset command.
|
||||||
|
if (input.Cmd == "reset")
|
||||||
|
{
|
||||||
|
if (input.Seed is int seed) rng = new Random(seed);
|
||||||
|
ship = RandomStartPose(rng);
|
||||||
|
stepCount = 0;
|
||||||
|
lastAction = 0;
|
||||||
|
WriteObservation((0.5f, 0.5f), reward: 0f, done: false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step command.
|
||||||
|
int action = input.Action ?? 0;
|
||||||
|
var target = (input.Target is { Length: >= 2 })
|
||||||
|
? (input.Target[0], input.Target[1])
|
||||||
|
: (0.5f, 0.5f);
|
||||||
|
|
||||||
|
ship = Physics.Step(ship, action);
|
||||||
|
lastAction = action;
|
||||||
|
stepCount++;
|
||||||
|
|
||||||
|
float reward = Reward.Compute(ship, target, action);
|
||||||
|
WriteObservation(target, reward, done: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers -------------------------------------------------------------
|
||||||
|
void WriteObservation((float X, float Y) target, float reward, bool done)
|
||||||
|
{
|
||||||
|
var output = new StepOutput
|
||||||
|
{
|
||||||
|
Obs = Observation.Build(ship, target),
|
||||||
|
State = new ShipStateDto
|
||||||
|
{
|
||||||
|
X = ship.X,
|
||||||
|
Y = ship.Y,
|
||||||
|
Angle = ship.Angle,
|
||||||
|
Engine = lastAction,
|
||||||
|
},
|
||||||
|
Reward = reward,
|
||||||
|
Done = done,
|
||||||
|
Step = stepCount,
|
||||||
|
};
|
||||||
|
Console.WriteLine(JsonSerializer.Serialize(output, ProtocolJsonContext.Default.StepOutput));
|
||||||
|
Console.Out.Flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
static ShipState RandomStartPose(Random rng)
|
||||||
|
{
|
||||||
|
// Random position in a comfortable box; zero velocity, near-upright.
|
||||||
|
float x = 0.2f + (float)rng.NextDouble() * 0.6f;
|
||||||
|
float y = 0.2f + (float)rng.NextDouble() * 0.4f;
|
||||||
|
float angle = ((float)rng.NextDouble() - 0.5f) * 0.4f; // ±0.2 rad
|
||||||
|
return new ShipState(x, y, 0f, 0f, angle, 0f);
|
||||||
|
}
|
||||||
56
GameCli/Protocol.cs
Normal file
56
GameCli/Protocol.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace GameCli;
|
||||||
|
|
||||||
|
/// <summary>Input line from the client (per step OR a reset command).</summary>
|
||||||
|
public sealed class StepInput
|
||||||
|
{
|
||||||
|
[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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Output line from the CLI after each step or reset.</summary>
|
||||||
|
public sealed class StepOutput
|
||||||
|
{
|
||||||
|
[JsonPropertyName("obs")] public float[] Obs { get; set; } = System.Array.Empty<float>();
|
||||||
|
[JsonPropertyName("state")] public ShipStateDto 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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Renderable ship pose (subset of ShipState that the UI actually draws).</summary>
|
||||||
|
public sealed class ShipStateDto
|
||||||
|
{
|
||||||
|
[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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Startup handshake emitted as the very first stdout line.</summary>
|
||||||
|
public sealed class InitMessage
|
||||||
|
{
|
||||||
|
[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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Source-generated JSON contracts for AOT-friendly, allocation-lean (de)serialization.</summary>
|
||||||
|
[JsonSourceGenerationOptions(WriteIndented = false)]
|
||||||
|
[JsonSerializable(typeof(StepInput))]
|
||||||
|
[JsonSerializable(typeof(StepOutput))]
|
||||||
|
[JsonSerializable(typeof(ShipStateDto))]
|
||||||
|
[JsonSerializable(typeof(InitMessage))]
|
||||||
|
[JsonSerializable(typeof(InitPayload))]
|
||||||
|
internal partial class ProtocolJsonContext : JsonSerializerContext
|
||||||
|
{
|
||||||
|
}
|
||||||
32
GameCli/Reward.cs
Normal file
32
GameCli/Reward.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
namespace GameCli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reward = -distance_to_target
|
||||||
|
/// - λ_v · speed
|
||||||
|
/// - λ_θ · |wrap(angle)|
|
||||||
|
/// - λ_fuel · engine_on
|
||||||
|
/// The angle is wrapped to [-π, π] so a ship that has completed N revolutions
|
||||||
|
/// gets the same upright-penalty as a physically identical pose that hasn't.
|
||||||
|
/// Coefficients are public so training scripts can log/inspect them.
|
||||||
|
/// </summary>
|
||||||
|
public static class Reward
|
||||||
|
{
|
||||||
|
public const float LambdaVelocity = 0.10f;
|
||||||
|
public const float LambdaAngle = 0.10f;
|
||||||
|
public const float LambdaFuel = 0.03f;
|
||||||
|
|
||||||
|
public static float Compute(ShipState s, (float X, float Y) target, int action)
|
||||||
|
{
|
||||||
|
float dx = target.X - s.X;
|
||||||
|
float dy = target.Y - s.Y;
|
||||||
|
float distance = MathF.Sqrt(dx * dx + dy * dy);
|
||||||
|
float speed = MathF.Sqrt(s.VX * s.VX + s.VY * s.VY);
|
||||||
|
float wrapped = MathF.Atan2(MathF.Sin(s.Angle), MathF.Cos(s.Angle));
|
||||||
|
float engineOn = action == Physics.ActionNoop ? 0f : 1f;
|
||||||
|
|
||||||
|
return -distance
|
||||||
|
- LambdaVelocity * speed
|
||||||
|
- LambdaAngle * MathF.Abs(wrapped)
|
||||||
|
- LambdaFuel * engineOn;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
GameCli/Ship.cs
Normal file
18
GameCli/Ship.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
namespace GameCli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Immutable ship pose + velocity in world coordinates.
|
||||||
|
/// World is [0,1] × [0,1]. Y increases downward (screen-native).
|
||||||
|
/// Angle=0 means "ship pointing up" (main engine points down).
|
||||||
|
/// Positive angle rotates clockwise.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct ShipState(
|
||||||
|
float X,
|
||||||
|
float Y,
|
||||||
|
float VX,
|
||||||
|
float VY,
|
||||||
|
float Angle,
|
||||||
|
float AngularVelocity)
|
||||||
|
{
|
||||||
|
public static ShipState AtCenter() => new(0.5f, 0.5f, 0f, 0f, 0f, 0f);
|
||||||
|
}
|
||||||
67
Training/export_onnx.py
Normal file
67
Training/export_onnx.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Export a trained SB3 PPO policy to ONNX for the .NET backend.
|
||||||
|
|
||||||
|
The exported model has a single float input of shape [batch, 7] (the observation
|
||||||
|
vector) and a single float output of shape [batch, 4] (action logits). Argmax
|
||||||
|
over the last axis gives the action.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import torch
|
||||||
|
from stable_baselines3 import PPO
|
||||||
|
|
||||||
|
|
||||||
|
class OnnxablePolicy(torch.nn.Module):
|
||||||
|
"""Wraps the SB3 policy's actor path for ONNX export.
|
||||||
|
|
||||||
|
Standard SB3 MlpPolicy for a discrete action space:
|
||||||
|
features = policy.extract_features(obs)
|
||||||
|
latent_pi = policy.mlp_extractor.forward_actor(features)
|
||||||
|
logits = policy.action_net(latent_pi)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, policy: torch.nn.Module) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.policy = policy
|
||||||
|
|
||||||
|
def forward(self, obs: torch.Tensor) -> torch.Tensor:
|
||||||
|
features = self.policy.extract_features(obs)
|
||||||
|
latent_pi = self.policy.mlp_extractor.forward_actor(features)
|
||||||
|
return self.policy.action_net(latent_pi)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--checkpoint", type=Path, required=True,
|
||||||
|
help="path to SB3 .zip checkpoint")
|
||||||
|
parser.add_argument("--out", type=Path, required=True,
|
||||||
|
help="destination .onnx path")
|
||||||
|
parser.add_argument("--opset", type=int, default=17)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
model = PPO.load(str(args.checkpoint), device="cpu")
|
||||||
|
model.policy.eval()
|
||||||
|
|
||||||
|
onnxable = OnnxablePolicy(model.policy)
|
||||||
|
onnxable.eval()
|
||||||
|
|
||||||
|
dummy = torch.randn(1, 7, dtype=torch.float32)
|
||||||
|
torch.onnx.export(
|
||||||
|
onnxable,
|
||||||
|
dummy,
|
||||||
|
str(args.out),
|
||||||
|
input_names=["obs"],
|
||||||
|
output_names=["logits"],
|
||||||
|
dynamic_axes={"obs": {0: "batch"}, "logits": {0: "batch"}},
|
||||||
|
opset_version=args.opset,
|
||||||
|
dynamo=False,
|
||||||
|
)
|
||||||
|
print(f"exported ONNX policy to {args.out}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
134
Training/lander_cli_env.py
Normal file
134
Training/lander_cli_env.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"""Gymnasium environment that wraps the GameCli C# binary as a subprocess.
|
||||||
|
|
||||||
|
Speaks the line-delimited JSON protocol defined in:
|
||||||
|
docs/superpowers/specs/2026-07-17-cursor-following-lander-design.md
|
||||||
|
|
||||||
|
Each episode picks a random target in [0,1]^2 at reset time and holds it fixed.
|
||||||
|
Never terminates — episodes end via truncation at max_episode_steps.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import gymnasium as gym
|
||||||
|
import numpy as np
|
||||||
|
from gymnasium import spaces
|
||||||
|
|
||||||
|
|
||||||
|
class LanderCliEnv(gym.Env):
|
||||||
|
"""One CLI subprocess per env instance. Not thread-safe; safe under SubprocVecEnv."""
|
||||||
|
|
||||||
|
metadata = {"render_modes": []}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
cli_binary: str,
|
||||||
|
max_episode_steps: int = 1000,
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._cli_binary = cli_binary
|
||||||
|
self._max_episode_steps = max_episode_steps
|
||||||
|
|
||||||
|
self._proc: Optional[subprocess.Popen] = None
|
||||||
|
self._target: tuple[float, float] = (0.5, 0.5)
|
||||||
|
self._step_count: int = 0
|
||||||
|
|
||||||
|
self._start_process()
|
||||||
|
self._read_init()
|
||||||
|
|
||||||
|
self.observation_space = spaces.Box(
|
||||||
|
low=-np.inf, high=np.inf, shape=(7,), dtype=np.float32
|
||||||
|
)
|
||||||
|
self.action_space = spaces.Discrete(4)
|
||||||
|
|
||||||
|
# -------- subprocess lifecycle --------
|
||||||
|
|
||||||
|
def _start_process(self) -> None:
|
||||||
|
self._proc = subprocess.Popen(
|
||||||
|
[self._cli_binary],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
bufsize=1, # line-buffered
|
||||||
|
)
|
||||||
|
|
||||||
|
def _read_init(self) -> None:
|
||||||
|
assert self._proc is not None and self._proc.stdout is not None
|
||||||
|
line = self._proc.stdout.readline()
|
||||||
|
if not line:
|
||||||
|
raise RuntimeError("CLI died before emitting init handshake")
|
||||||
|
msg = json.loads(line)
|
||||||
|
if "init" not in msg:
|
||||||
|
raise RuntimeError(f"expected init handshake, got: {line!r}")
|
||||||
|
|
||||||
|
def _send(self, obj: dict[str, Any]) -> None:
|
||||||
|
assert self._proc is not None and self._proc.stdin is not None
|
||||||
|
self._proc.stdin.write(json.dumps(obj) + "\n")
|
||||||
|
self._proc.stdin.flush()
|
||||||
|
|
||||||
|
def _recv(self) -> dict[str, Any]:
|
||||||
|
assert self._proc is not None and self._proc.stdout is not None
|
||||||
|
line = self._proc.stdout.readline()
|
||||||
|
if not line:
|
||||||
|
raise RuntimeError("CLI closed stdout unexpectedly")
|
||||||
|
return json.loads(line)
|
||||||
|
|
||||||
|
# -------- gym.Env API --------
|
||||||
|
|
||||||
|
def reset(
|
||||||
|
self, *, seed: Optional[int] = None, options: Optional[dict] = None
|
||||||
|
) -> tuple[np.ndarray, dict]:
|
||||||
|
super().reset(seed=seed)
|
||||||
|
# Sample a fresh target each episode; the Gymnasium-provided
|
||||||
|
# np_random is deterministic given `seed`.
|
||||||
|
tx = float(self.np_random.uniform(0.05, 0.95))
|
||||||
|
ty = float(self.np_random.uniform(0.05, 0.95))
|
||||||
|
self._target = (tx, ty)
|
||||||
|
self._step_count = 0
|
||||||
|
|
||||||
|
cmd: dict[str, Any] = {"cmd": "reset"}
|
||||||
|
if seed is not None:
|
||||||
|
cmd["seed"] = int(seed)
|
||||||
|
self._send(cmd)
|
||||||
|
msg = self._recv()
|
||||||
|
obs = np.asarray(msg["obs"], dtype=np.float32)
|
||||||
|
# Overwrite the CLI's dummy (0.5, 0.5) target displacement with the real one.
|
||||||
|
# dx = target.X - ship.X, dy = target.Y - ship.Y.
|
||||||
|
obs[0] = tx - msg["state"]["x"]
|
||||||
|
obs[1] = ty - msg["state"]["y"]
|
||||||
|
return obs, {}
|
||||||
|
|
||||||
|
def step(self, action: int) -> tuple[np.ndarray, float, bool, bool, dict]:
|
||||||
|
self._send({"action": int(action), "target": list(self._target)})
|
||||||
|
msg = self._recv()
|
||||||
|
obs = np.asarray(msg["obs"], dtype=np.float32)
|
||||||
|
reward = float(msg["reward"])
|
||||||
|
self._step_count += 1
|
||||||
|
truncated = self._step_count >= self._max_episode_steps
|
||||||
|
terminated = False # continuous hover task
|
||||||
|
return obs, reward, terminated, truncated, {}
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self._proc is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if self._proc.stdin is not None:
|
||||||
|
self._proc.stdin.close()
|
||||||
|
except (BrokenPipeError, OSError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
self._proc.wait(timeout=3)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self._proc.kill()
|
||||||
|
self._proc.wait(timeout=1)
|
||||||
|
self._proc = None
|
||||||
|
|
||||||
|
def __del__(self) -> None:
|
||||||
|
try:
|
||||||
|
self.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
4
Training/pytest.ini
Normal file
4
Training/pytest.ini
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
python_files = test_*.py
|
||||||
|
addopts = -v
|
||||||
8
Training/requirements.txt
Normal file
8
Training/requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
gymnasium>=0.29,<2
|
||||||
|
stable-baselines3>=2.3,<3
|
||||||
|
torch>=2.2,<3
|
||||||
|
onnx>=1.16,<2
|
||||||
|
onnxruntime>=1.18,<2
|
||||||
|
numpy>=1.24,<3
|
||||||
|
tensorboard>=2.15
|
||||||
|
pytest>=8
|
||||||
0
Training/tests/__init__.py
Normal file
0
Training/tests/__init__.py
Normal file
68
Training/tests/test_export_onnx.py
Normal file
68
Training/tests/test_export_onnx.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""Verify export_onnx.py produces an ONNX file that matches the SB3 policy."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
CHECKPOINT = REPO_ROOT / "checkpoints" / "ppo_lander_final.zip"
|
||||||
|
ONNX_OUT = REPO_ROOT / "models" / "ppo_lander.onnx"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def exported_onnx():
|
||||||
|
if not CHECKPOINT.exists():
|
||||||
|
pytest.skip(f"no checkpoint at {CHECKPOINT} — run train.py first")
|
||||||
|
# export
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
training_dir = REPO_ROOT / "Training"
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(training_dir / "export_onnx.py"),
|
||||||
|
"--checkpoint", str(CHECKPOINT), "--out", str(ONNX_OUT)],
|
||||||
|
capture_output=True, text=True, cwd=str(REPO_ROOT),
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, f"export failed: {result.stderr}"
|
||||||
|
assert ONNX_OUT.exists(), f"ONNX file not produced at {ONNX_OUT}"
|
||||||
|
return ONNX_OUT
|
||||||
|
|
||||||
|
|
||||||
|
def test_onnx_file_exists(exported_onnx):
|
||||||
|
assert exported_onnx.stat().st_size > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_onnx_input_output_shapes(exported_onnx):
|
||||||
|
import onnx
|
||||||
|
model = onnx.load(str(exported_onnx))
|
||||||
|
assert len(model.graph.input) == 1
|
||||||
|
in_shape = [d.dim_value for d in model.graph.input[0].type.tensor_type.shape.dim]
|
||||||
|
# (batch, 7)
|
||||||
|
assert in_shape[-1] == 7
|
||||||
|
assert len(model.graph.output) >= 1
|
||||||
|
out_shape = [d.dim_value for d in model.graph.output[0].type.tensor_type.shape.dim]
|
||||||
|
assert out_shape[-1] == 4 # 4 discrete actions
|
||||||
|
|
||||||
|
|
||||||
|
def test_onnx_output_matches_sb3(exported_onnx):
|
||||||
|
"""Sanity: ONNX inference for 100 random obs must argmax to the same action as SB3."""
|
||||||
|
import onnxruntime
|
||||||
|
from stable_baselines3 import PPO
|
||||||
|
|
||||||
|
model = PPO.load(str(CHECKPOINT))
|
||||||
|
session = onnxruntime.InferenceSession(str(exported_onnx))
|
||||||
|
input_name = session.get_inputs()[0].name
|
||||||
|
|
||||||
|
rng = np.random.default_rng(1234)
|
||||||
|
obs_batch = rng.normal(size=(100, 7)).astype(np.float32)
|
||||||
|
|
||||||
|
# ONNX: (100, 4) logits
|
||||||
|
onnx_logits = session.run(None, {input_name: obs_batch})[0]
|
||||||
|
onnx_actions = onnx_logits.argmax(axis=-1)
|
||||||
|
|
||||||
|
# SB3 deterministic prediction
|
||||||
|
sb3_actions, _ = model.predict(obs_batch, deterministic=True)
|
||||||
|
|
||||||
|
mismatches = int((onnx_actions != sb3_actions).sum())
|
||||||
|
# Allow up to 2 mismatches out of 100 for float rounding on marginal states.
|
||||||
|
assert mismatches <= 2, f"{mismatches}/100 argmax mismatches between ONNX and SB3"
|
||||||
103
Training/tests/test_lander_cli_env.py
Normal file
103
Training/tests/test_lander_cli_env.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""Unit tests for LanderCliEnv."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
from gymnasium.utils.env_checker import check_env
|
||||||
|
|
||||||
|
# Add parent dir to sys.path so we can import lander_cli_env
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from lander_cli_env import LanderCliEnv
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
CLI_BINARY = REPO_ROOT / "publish" / "GameCli" / "GameCli"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def cli_binary_exists():
|
||||||
|
if not CLI_BINARY.exists():
|
||||||
|
pytest.skip(f"CLI binary not found at {CLI_BINARY} — run `dotnet publish GameCli -c Release -o publish/GameCli`")
|
||||||
|
return CLI_BINARY
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_opens_and_closes(cli_binary_exists):
|
||||||
|
env = LanderCliEnv(cli_binary=str(cli_binary_exists))
|
||||||
|
env.close() # should not raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_observation_space_is_seven_dim(cli_binary_exists):
|
||||||
|
env = LanderCliEnv(cli_binary=str(cli_binary_exists))
|
||||||
|
try:
|
||||||
|
assert env.observation_space.shape == (7,)
|
||||||
|
finally:
|
||||||
|
env.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_action_space_is_discrete_four(cli_binary_exists):
|
||||||
|
env = LanderCliEnv(cli_binary=str(cli_binary_exists))
|
||||||
|
try:
|
||||||
|
assert env.action_space.n == 4
|
||||||
|
finally:
|
||||||
|
env.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_returns_seven_dim_observation(cli_binary_exists):
|
||||||
|
env = LanderCliEnv(cli_binary=str(cli_binary_exists))
|
||||||
|
try:
|
||||||
|
obs, info = env.reset(seed=42)
|
||||||
|
assert isinstance(obs, np.ndarray)
|
||||||
|
assert obs.shape == (7,)
|
||||||
|
assert obs.dtype == np.float32
|
||||||
|
finally:
|
||||||
|
env.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_step_advances_and_returns_five_tuple(cli_binary_exists):
|
||||||
|
env = LanderCliEnv(cli_binary=str(cli_binary_exists))
|
||||||
|
try:
|
||||||
|
env.reset(seed=0)
|
||||||
|
obs, reward, terminated, truncated, info = env.step(0) # noop
|
||||||
|
assert obs.shape == (7,)
|
||||||
|
assert isinstance(reward, float)
|
||||||
|
assert terminated is False # never terminates
|
||||||
|
assert isinstance(truncated, bool)
|
||||||
|
finally:
|
||||||
|
env.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_randomizes_target_across_episodes(cli_binary_exists):
|
||||||
|
env = LanderCliEnv(cli_binary=str(cli_binary_exists))
|
||||||
|
try:
|
||||||
|
# Two resets with different seeds should generally give different targets.
|
||||||
|
env.reset(seed=1)
|
||||||
|
target1 = env._target
|
||||||
|
env.reset(seed=2)
|
||||||
|
target2 = env._target
|
||||||
|
assert target1 != target2
|
||||||
|
finally:
|
||||||
|
env.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_truncation_at_max_steps(cli_binary_exists):
|
||||||
|
env = LanderCliEnv(cli_binary=str(cli_binary_exists), max_episode_steps=5)
|
||||||
|
try:
|
||||||
|
env.reset(seed=0)
|
||||||
|
truncated = False
|
||||||
|
for _ in range(5):
|
||||||
|
_, _, _, truncated, _ = env.step(0)
|
||||||
|
assert truncated is True
|
||||||
|
finally:
|
||||||
|
env.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_env_passes(cli_binary_exists):
|
||||||
|
"""Gymnasium's own env sanity checker — spaces, dtypes, reset/step contract."""
|
||||||
|
env = LanderCliEnv(cli_binary=str(cli_binary_exists), max_episode_steps=50)
|
||||||
|
try:
|
||||||
|
# skip_render_check because we don't implement rendering
|
||||||
|
check_env(env.unwrapped, skip_render_check=True)
|
||||||
|
finally:
|
||||||
|
env.close()
|
||||||
96
Training/train.py
Normal file
96
Training/train.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"""Train a PPO policy on LanderCliEnv.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python train.py --steps 100000 # short smoke run
|
||||||
|
python train.py --steps 2000000 --n-envs 8 # full training
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from stable_baselines3 import PPO
|
||||||
|
from stable_baselines3.common.callbacks import CheckpointCallback
|
||||||
|
from stable_baselines3.common.vec_env import SubprocVecEnv, DummyVecEnv
|
||||||
|
|
||||||
|
# sys.path shim so this script can be run from any CWD.
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
|
from lander_cli_env import LanderCliEnv
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
CLI_BINARY = REPO_ROOT / "publish" / "GameCli" / "GameCli"
|
||||||
|
|
||||||
|
|
||||||
|
def make_env(seed: int, max_episode_steps: int):
|
||||||
|
"""Return a thunk that SubprocVecEnv can call to construct one env."""
|
||||||
|
def _fn():
|
||||||
|
env = LanderCliEnv(
|
||||||
|
cli_binary=str(CLI_BINARY),
|
||||||
|
max_episode_steps=max_episode_steps,
|
||||||
|
)
|
||||||
|
env.reset(seed=seed)
|
||||||
|
return env
|
||||||
|
return _fn
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--steps", type=int, default=2_000_000,
|
||||||
|
help="total env steps to train for")
|
||||||
|
parser.add_argument("--n-envs", type=int, default=8,
|
||||||
|
help="parallel envs; each spawns one GameCli subprocess")
|
||||||
|
parser.add_argument("--max-episode-steps", type=int, default=1000)
|
||||||
|
parser.add_argument("--seed", type=int, default=0)
|
||||||
|
parser.add_argument("--checkpoint-dir", type=Path,
|
||||||
|
default=REPO_ROOT / "checkpoints")
|
||||||
|
parser.add_argument("--tb-dir", type=Path,
|
||||||
|
default=REPO_ROOT / "tensorboard")
|
||||||
|
parser.add_argument("--use-dummy", action="store_true",
|
||||||
|
help="run envs in-process (slower, easier to debug)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not CLI_BINARY.exists():
|
||||||
|
raise SystemExit(
|
||||||
|
f"CLI binary not found at {CLI_BINARY}. Run: "
|
||||||
|
f"dotnet publish GameCli/GameCli.csproj -c Release -o publish/GameCli"
|
||||||
|
)
|
||||||
|
|
||||||
|
args.checkpoint_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
args.tb_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
env_fns = [
|
||||||
|
make_env(seed=args.seed + i, max_episode_steps=args.max_episode_steps)
|
||||||
|
for i in range(args.n_envs)
|
||||||
|
]
|
||||||
|
vec_env_cls = DummyVecEnv if args.use_dummy else SubprocVecEnv
|
||||||
|
vec_env = vec_env_cls(env_fns)
|
||||||
|
|
||||||
|
model = PPO(
|
||||||
|
"MlpPolicy",
|
||||||
|
vec_env,
|
||||||
|
verbose=1,
|
||||||
|
seed=args.seed,
|
||||||
|
tensorboard_log=str(args.tb_dir),
|
||||||
|
policy_kwargs=dict(net_arch=[64, 64]),
|
||||||
|
)
|
||||||
|
|
||||||
|
checkpoint_cb = CheckpointCallback(
|
||||||
|
save_freq=max(args.steps // 10, 1) // args.n_envs,
|
||||||
|
save_path=str(args.checkpoint_dir),
|
||||||
|
name_prefix="ppo_lander",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
model.learn(total_timesteps=args.steps, callback=checkpoint_cb)
|
||||||
|
final_path = args.checkpoint_dir / "ppo_lander_final.zip"
|
||||||
|
model.save(str(final_path))
|
||||||
|
print(f"saved final model to {final_path}")
|
||||||
|
finally:
|
||||||
|
vec_env.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
1090
docs/superpowers/plans/2026-07-17-01-game-cli.md
Normal file
1090
docs/superpowers/plans/2026-07-17-01-game-cli.md
Normal file
File diff suppressed because it is too large
Load Diff
789
docs/superpowers/plans/2026-07-17-02-training-pipeline.md
Normal file
789
docs/superpowers/plans/2026-07-17-02-training-pipeline.md
Normal file
@@ -0,0 +1,789 @@
|
|||||||
|
# Training Pipeline 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 Python side of the design: a `LanderCliEnv` Gymnasium environment that wraps the `GameCli` binary as a subprocess, a `train.py` that trains a PPO policy on N vectorized copies of it, and an `export_onnx.py` that exports the trained network as `models/ppo_lander.onnx` for the ASP.NET backend to load. Reference spec at `docs/superpowers/specs/2026-07-17-cursor-following-lander-design.md`.
|
||||||
|
|
||||||
|
**Architecture:** All Python code lives under `Training/`. `LanderCliEnv` speaks the exact protocol the `GameCli` documents on stdio, so training uses literally the same physics as inference. Training runs a `SubprocVecEnv` of N envs → PPO batches transitions → checkpoints to `checkpoints/` → export → verify → `models/ppo_lander.onnx`.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.11 or 3.12, `gymnasium`, `stable-baselines3`, `torch`, `onnx`, `onnxruntime`, `pytest`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Plan 1 complete.** The `GameCli` binary must be publishable at `publish/GameCli/GameCli` (run `dotnet publish GameCli/GameCli.csproj -c Release -o publish/GameCli` if it's not there yet). All Training/ code assumes that path.
|
||||||
|
- Python 3.11 or 3.12 available as `python3`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
```
|
||||||
|
Experiment_ReinforcementLearning/
|
||||||
|
├── Training/
|
||||||
|
│ ├── requirements.txt
|
||||||
|
│ ├── pytest.ini
|
||||||
|
│ ├── lander_cli_env.py # LanderCliEnv(gym.Env)
|
||||||
|
│ ├── train.py # PPO training with SubprocVecEnv
|
||||||
|
│ ├── export_onnx.py # SB3 -> ONNX
|
||||||
|
│ └── tests/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── test_lander_cli_env.py
|
||||||
|
│ └── test_export_onnx.py
|
||||||
|
├── models/ # gitignored; export_onnx.py writes ppo_lander.onnx here
|
||||||
|
├── checkpoints/ # gitignored; SB3 checkpoints
|
||||||
|
└── tensorboard/ # gitignored; TB logs
|
||||||
|
```
|
||||||
|
|
||||||
|
`Training/lander_cli_env.py` is the single file that touches the subprocess. `train.py` and `export_onnx.py` just consume Gymnasium/SB3 APIs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Scaffold Python project
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `Training/requirements.txt`
|
||||||
|
- Create: `Training/pytest.ini`
|
||||||
|
- Create: `Training/tests/__init__.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Check Python is available**
|
||||||
|
|
||||||
|
Run: `python3 --version`
|
||||||
|
Expected: `Python 3.11.x` or `3.12.x`. If older or missing, STOP and report BLOCKED.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create the virtualenv at repo root**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install --upgrade pip
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write `Training/requirements.txt`**
|
||||||
|
|
||||||
|
```
|
||||||
|
gymnasium>=0.29,<2
|
||||||
|
stable-baselines3>=2.3,<3
|
||||||
|
torch>=2.2,<3
|
||||||
|
onnx>=1.16,<2
|
||||||
|
onnxruntime>=1.18,<2
|
||||||
|
numpy>=1.24,<3
|
||||||
|
tensorboard>=2.15
|
||||||
|
pytest>=8
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Install dependencies**
|
||||||
|
|
||||||
|
Run: `pip install -r Training/requirements.txt`
|
||||||
|
Expected: successful install. This may take a few minutes (torch is large).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Write `Training/pytest.ini`**
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
python_files = test_*.py
|
||||||
|
addopts = -v
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Write `Training/tests/__init__.py`** (empty file)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
touch Training/tests/__init__.py
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Verify pytest can discover an empty test suite**
|
||||||
|
|
||||||
|
From `Training/` directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd Training && pytest && cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `no tests ran` (0 tests, exit code 0 or 5 — both mean "no tests but no failures"). Exit code 5 is fine.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add Training/
|
||||||
|
git -c user.email=meelstorm@gmail.com -c user.name=meelstorm commit -m "feat(training): scaffold Python training project"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `LanderCliEnv` — Gymnasium environment wrapping the CLI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `Training/lander_cli_env.py`
|
||||||
|
- Create: `Training/tests/test_lander_cli_env.py`
|
||||||
|
|
||||||
|
### Protocol quick reference (from Plan 1)
|
||||||
|
|
||||||
|
- CLI startup emits: `{"init":{"world":[1,1],"dt":0.02,"obs_dim":7,"n_actions":4}}`
|
||||||
|
- Step input: `{"action":<int>,"target":[<float>,<float>]}`
|
||||||
|
- Reset input: `{"cmd":"reset","seed":<int>}` (seed optional)
|
||||||
|
- Output line: `{"obs":[...7 floats...],"state":{...},"reward":<float>,"done":<bool>,"step":<int>}`
|
||||||
|
- CLI binary path (relative to repo root): `publish/GameCli/GameCli`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Create `Training/tests/test_lander_cli_env.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Unit tests for LanderCliEnv."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
from gymnasium.utils.env_checker import check_env
|
||||||
|
|
||||||
|
# Add parent dir to sys.path so we can import lander_cli_env
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from lander_cli_env import LanderCliEnv
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
CLI_BINARY = REPO_ROOT / "publish" / "GameCli" / "GameCli"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def cli_binary_exists():
|
||||||
|
if not CLI_BINARY.exists():
|
||||||
|
pytest.skip(f"CLI binary not found at {CLI_BINARY} — run `dotnet publish GameCli -c Release -o publish/GameCli`")
|
||||||
|
return CLI_BINARY
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_opens_and_closes(cli_binary_exists):
|
||||||
|
env = LanderCliEnv(cli_binary=str(cli_binary_exists))
|
||||||
|
env.close() # should not raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_observation_space_is_seven_dim(cli_binary_exists):
|
||||||
|
env = LanderCliEnv(cli_binary=str(cli_binary_exists))
|
||||||
|
try:
|
||||||
|
assert env.observation_space.shape == (7,)
|
||||||
|
finally:
|
||||||
|
env.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_action_space_is_discrete_four(cli_binary_exists):
|
||||||
|
env = LanderCliEnv(cli_binary=str(cli_binary_exists))
|
||||||
|
try:
|
||||||
|
assert env.action_space.n == 4
|
||||||
|
finally:
|
||||||
|
env.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_returns_seven_dim_observation(cli_binary_exists):
|
||||||
|
env = LanderCliEnv(cli_binary=str(cli_binary_exists))
|
||||||
|
try:
|
||||||
|
obs, info = env.reset(seed=42)
|
||||||
|
assert isinstance(obs, np.ndarray)
|
||||||
|
assert obs.shape == (7,)
|
||||||
|
assert obs.dtype == np.float32
|
||||||
|
finally:
|
||||||
|
env.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_step_advances_and_returns_five_tuple(cli_binary_exists):
|
||||||
|
env = LanderCliEnv(cli_binary=str(cli_binary_exists))
|
||||||
|
try:
|
||||||
|
env.reset(seed=0)
|
||||||
|
obs, reward, terminated, truncated, info = env.step(0) # noop
|
||||||
|
assert obs.shape == (7,)
|
||||||
|
assert isinstance(reward, float)
|
||||||
|
assert terminated is False # never terminates
|
||||||
|
assert isinstance(truncated, bool)
|
||||||
|
finally:
|
||||||
|
env.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_randomizes_target_across_episodes(cli_binary_exists):
|
||||||
|
env = LanderCliEnv(cli_binary=str(cli_binary_exists))
|
||||||
|
try:
|
||||||
|
# Two resets with different seeds should generally give different targets.
|
||||||
|
env.reset(seed=1)
|
||||||
|
target1 = env._target
|
||||||
|
env.reset(seed=2)
|
||||||
|
target2 = env._target
|
||||||
|
assert target1 != target2
|
||||||
|
finally:
|
||||||
|
env.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_truncation_at_max_steps(cli_binary_exists):
|
||||||
|
env = LanderCliEnv(cli_binary=str(cli_binary_exists), max_episode_steps=5)
|
||||||
|
try:
|
||||||
|
env.reset(seed=0)
|
||||||
|
truncated = False
|
||||||
|
for _ in range(5):
|
||||||
|
_, _, _, truncated, _ = env.step(0)
|
||||||
|
assert truncated is True
|
||||||
|
finally:
|
||||||
|
env.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_env_passes(cli_binary_exists):
|
||||||
|
"""Gymnasium's own env sanity checker — spaces, dtypes, reset/step contract."""
|
||||||
|
env = LanderCliEnv(cli_binary=str(cli_binary_exists), max_episode_steps=50)
|
||||||
|
try:
|
||||||
|
# skip_render_check because we don't implement rendering
|
||||||
|
check_env(env.unwrapped, skip_render_check=True)
|
||||||
|
finally:
|
||||||
|
env.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd Training && pytest && cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: import error — `lander_cli_env` module does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write `Training/lander_cli_env.py`**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Gymnasium environment that wraps the GameCli C# binary as a subprocess.
|
||||||
|
|
||||||
|
Speaks the line-delimited JSON protocol defined in:
|
||||||
|
docs/superpowers/specs/2026-07-17-cursor-following-lander-design.md
|
||||||
|
|
||||||
|
Each episode picks a random target in [0,1]^2 at reset time and holds it fixed.
|
||||||
|
Never terminates — episodes end via truncation at max_episode_steps.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import gymnasium as gym
|
||||||
|
import numpy as np
|
||||||
|
from gymnasium import spaces
|
||||||
|
|
||||||
|
|
||||||
|
class LanderCliEnv(gym.Env):
|
||||||
|
"""One CLI subprocess per env instance. Not thread-safe; safe under SubprocVecEnv."""
|
||||||
|
|
||||||
|
metadata = {"render_modes": []}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
cli_binary: str,
|
||||||
|
max_episode_steps: int = 1000,
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._cli_binary = cli_binary
|
||||||
|
self._max_episode_steps = max_episode_steps
|
||||||
|
|
||||||
|
self._proc: Optional[subprocess.Popen] = None
|
||||||
|
self._target: tuple[float, float] = (0.5, 0.5)
|
||||||
|
self._step_count: int = 0
|
||||||
|
|
||||||
|
self._start_process()
|
||||||
|
self._read_init()
|
||||||
|
|
||||||
|
self.observation_space = spaces.Box(
|
||||||
|
low=-np.inf, high=np.inf, shape=(7,), dtype=np.float32
|
||||||
|
)
|
||||||
|
self.action_space = spaces.Discrete(4)
|
||||||
|
|
||||||
|
# -------- subprocess lifecycle --------
|
||||||
|
|
||||||
|
def _start_process(self) -> None:
|
||||||
|
self._proc = subprocess.Popen(
|
||||||
|
[self._cli_binary],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
bufsize=1, # line-buffered
|
||||||
|
)
|
||||||
|
|
||||||
|
def _read_init(self) -> None:
|
||||||
|
assert self._proc is not None and self._proc.stdout is not None
|
||||||
|
line = self._proc.stdout.readline()
|
||||||
|
if not line:
|
||||||
|
raise RuntimeError("CLI died before emitting init handshake")
|
||||||
|
msg = json.loads(line)
|
||||||
|
if "init" not in msg:
|
||||||
|
raise RuntimeError(f"expected init handshake, got: {line!r}")
|
||||||
|
|
||||||
|
def _send(self, obj: dict[str, Any]) -> None:
|
||||||
|
assert self._proc is not None and self._proc.stdin is not None
|
||||||
|
self._proc.stdin.write(json.dumps(obj) + "\n")
|
||||||
|
self._proc.stdin.flush()
|
||||||
|
|
||||||
|
def _recv(self) -> dict[str, Any]:
|
||||||
|
assert self._proc is not None and self._proc.stdout is not None
|
||||||
|
line = self._proc.stdout.readline()
|
||||||
|
if not line:
|
||||||
|
raise RuntimeError("CLI closed stdout unexpectedly")
|
||||||
|
return json.loads(line)
|
||||||
|
|
||||||
|
# -------- gym.Env API --------
|
||||||
|
|
||||||
|
def reset(
|
||||||
|
self, *, seed: Optional[int] = None, options: Optional[dict] = None
|
||||||
|
) -> tuple[np.ndarray, dict]:
|
||||||
|
super().reset(seed=seed)
|
||||||
|
# Sample a fresh target each episode; the Gymnasium-provided
|
||||||
|
# np_random is deterministic given `seed`.
|
||||||
|
tx = float(self.np_random.uniform(0.05, 0.95))
|
||||||
|
ty = float(self.np_random.uniform(0.05, 0.95))
|
||||||
|
self._target = (tx, ty)
|
||||||
|
self._step_count = 0
|
||||||
|
|
||||||
|
cmd: dict[str, Any] = {"cmd": "reset"}
|
||||||
|
if seed is not None:
|
||||||
|
cmd["seed"] = int(seed)
|
||||||
|
self._send(cmd)
|
||||||
|
msg = self._recv()
|
||||||
|
obs = np.asarray(msg["obs"], dtype=np.float32)
|
||||||
|
# Overwrite the CLI's dummy (0.5, 0.5) target displacement with the real one.
|
||||||
|
# dx = target.X - ship.X, dy = target.Y - ship.Y.
|
||||||
|
obs[0] = tx - msg["state"]["x"]
|
||||||
|
obs[1] = ty - msg["state"]["y"]
|
||||||
|
return obs, {}
|
||||||
|
|
||||||
|
def step(self, action: int) -> tuple[np.ndarray, float, bool, bool, dict]:
|
||||||
|
self._send({"action": int(action), "target": list(self._target)})
|
||||||
|
msg = self._recv()
|
||||||
|
obs = np.asarray(msg["obs"], dtype=np.float32)
|
||||||
|
reward = float(msg["reward"])
|
||||||
|
self._step_count += 1
|
||||||
|
truncated = self._step_count >= self._max_episode_steps
|
||||||
|
terminated = False # continuous hover task
|
||||||
|
return obs, reward, terminated, truncated, {}
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self._proc is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if self._proc.stdin is not None:
|
||||||
|
self._proc.stdin.close()
|
||||||
|
except (BrokenPipeError, OSError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
self._proc.wait(timeout=3)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self._proc.kill()
|
||||||
|
self._proc.wait(timeout=1)
|
||||||
|
self._proc = None
|
||||||
|
|
||||||
|
def __del__(self) -> None:
|
||||||
|
try:
|
||||||
|
self.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Publish the CLI binary (prerequisite for tests)**
|
||||||
|
|
||||||
|
If `publish/GameCli/GameCli` doesn't exist yet, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet publish GameCli/GameCli.csproj -c Release -o publish/GameCli
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests to verify they pass**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd Training && pytest tests/test_lander_cli_env.py && cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 8 passed, 0 failed.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add Training/lander_cli_env.py Training/tests/test_lander_cli_env.py
|
||||||
|
git -c user.email=meelstorm@gmail.com -c user.name=meelstorm commit -m "feat(training): implement LanderCliEnv wrapping the GameCli subprocess"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: `train.py` — PPO training with vectorized envs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `Training/train.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write `Training/train.py`**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Train a PPO policy on LanderCliEnv.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python train.py --steps 100000 # short smoke run
|
||||||
|
python train.py --steps 2000000 --n-envs 8 # full training
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from stable_baselines3 import PPO
|
||||||
|
from stable_baselines3.common.callbacks import CheckpointCallback
|
||||||
|
from stable_baselines3.common.vec_env import SubprocVecEnv, DummyVecEnv
|
||||||
|
|
||||||
|
# sys.path shim so this script can be run from any CWD.
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
|
from lander_cli_env import LanderCliEnv
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
CLI_BINARY = REPO_ROOT / "publish" / "GameCli" / "GameCli"
|
||||||
|
|
||||||
|
|
||||||
|
def make_env(seed: int, max_episode_steps: int):
|
||||||
|
"""Return a thunk that SubprocVecEnv can call to construct one env."""
|
||||||
|
def _fn():
|
||||||
|
env = LanderCliEnv(
|
||||||
|
cli_binary=str(CLI_BINARY),
|
||||||
|
max_episode_steps=max_episode_steps,
|
||||||
|
)
|
||||||
|
env.reset(seed=seed)
|
||||||
|
return env
|
||||||
|
return _fn
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--steps", type=int, default=2_000_000,
|
||||||
|
help="total env steps to train for")
|
||||||
|
parser.add_argument("--n-envs", type=int, default=8,
|
||||||
|
help="parallel envs; each spawns one GameCli subprocess")
|
||||||
|
parser.add_argument("--max-episode-steps", type=int, default=1000)
|
||||||
|
parser.add_argument("--seed", type=int, default=0)
|
||||||
|
parser.add_argument("--checkpoint-dir", type=Path,
|
||||||
|
default=REPO_ROOT / "checkpoints")
|
||||||
|
parser.add_argument("--tb-dir", type=Path,
|
||||||
|
default=REPO_ROOT / "tensorboard")
|
||||||
|
parser.add_argument("--use-dummy", action="store_true",
|
||||||
|
help="run envs in-process (slower, easier to debug)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not CLI_BINARY.exists():
|
||||||
|
raise SystemExit(
|
||||||
|
f"CLI binary not found at {CLI_BINARY}. Run: "
|
||||||
|
f"dotnet publish GameCli/GameCli.csproj -c Release -o publish/GameCli"
|
||||||
|
)
|
||||||
|
|
||||||
|
args.checkpoint_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
args.tb_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
env_fns = [
|
||||||
|
make_env(seed=args.seed + i, max_episode_steps=args.max_episode_steps)
|
||||||
|
for i in range(args.n_envs)
|
||||||
|
]
|
||||||
|
vec_env_cls = DummyVecEnv if args.use_dummy else SubprocVecEnv
|
||||||
|
vec_env = vec_env_cls(env_fns)
|
||||||
|
|
||||||
|
model = PPO(
|
||||||
|
"MlpPolicy",
|
||||||
|
vec_env,
|
||||||
|
verbose=1,
|
||||||
|
seed=args.seed,
|
||||||
|
tensorboard_log=str(args.tb_dir),
|
||||||
|
policy_kwargs=dict(net_arch=[64, 64]),
|
||||||
|
)
|
||||||
|
|
||||||
|
checkpoint_cb = CheckpointCallback(
|
||||||
|
save_freq=max(args.steps // 10, 1) // args.n_envs,
|
||||||
|
save_path=str(args.checkpoint_dir),
|
||||||
|
name_prefix="ppo_lander",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
model.learn(total_timesteps=args.steps, callback=checkpoint_cb)
|
||||||
|
final_path = args.checkpoint_dir / "ppo_lander_final.zip"
|
||||||
|
model.save(str(final_path))
|
||||||
|
print(f"saved final model to {final_path}")
|
||||||
|
finally:
|
||||||
|
vec_env.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Publish binary (idempotent)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet publish GameCli/GameCli.csproj -c Release -o publish/GameCli
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Smoke-run training for a small step count**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate
|
||||||
|
cd Training && python train.py --steps 5000 --n-envs 2 --use-dummy --max-episode-steps 200 && cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- SB3 prints the standard rollout table (`| rollout/ | ...`).
|
||||||
|
- Final line: `saved final model to .../checkpoints/ppo_lander_final.zip`.
|
||||||
|
- Exit code 0.
|
||||||
|
- The file `checkpoints/ppo_lander_final.zip` exists.
|
||||||
|
|
||||||
|
The `--use-dummy` flag runs envs in-process which is easier for debugging. Real training uses SubprocVecEnv (leave that flag off).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add Training/train.py
|
||||||
|
git -c user.email=meelstorm@gmail.com -c user.name=meelstorm commit -m "feat(training): PPO training script with vectorized LanderCliEnv"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: `export_onnx.py` — export trained policy to ONNX
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `Training/export_onnx.py`
|
||||||
|
- Create: `Training/tests/test_export_onnx.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Create `Training/tests/test_export_onnx.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Verify export_onnx.py produces an ONNX file that matches the SB3 policy."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
CHECKPOINT = REPO_ROOT / "checkpoints" / "ppo_lander_final.zip"
|
||||||
|
ONNX_OUT = REPO_ROOT / "models" / "ppo_lander.onnx"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def exported_onnx():
|
||||||
|
if not CHECKPOINT.exists():
|
||||||
|
pytest.skip(f"no checkpoint at {CHECKPOINT} — run train.py first")
|
||||||
|
# export
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
training_dir = REPO_ROOT / "Training"
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(training_dir / "export_onnx.py"),
|
||||||
|
"--checkpoint", str(CHECKPOINT), "--out", str(ONNX_OUT)],
|
||||||
|
capture_output=True, text=True, cwd=str(REPO_ROOT),
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, f"export failed: {result.stderr}"
|
||||||
|
assert ONNX_OUT.exists(), f"ONNX file not produced at {ONNX_OUT}"
|
||||||
|
return ONNX_OUT
|
||||||
|
|
||||||
|
|
||||||
|
def test_onnx_file_exists(exported_onnx):
|
||||||
|
assert exported_onnx.stat().st_size > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_onnx_input_output_shapes(exported_onnx):
|
||||||
|
import onnx
|
||||||
|
model = onnx.load(str(exported_onnx))
|
||||||
|
assert len(model.graph.input) == 1
|
||||||
|
in_shape = [d.dim_value for d in model.graph.input[0].type.tensor_type.shape.dim]
|
||||||
|
# (batch, 7)
|
||||||
|
assert in_shape[-1] == 7
|
||||||
|
assert len(model.graph.output) >= 1
|
||||||
|
out_shape = [d.dim_value for d in model.graph.output[0].type.tensor_type.shape.dim]
|
||||||
|
assert out_shape[-1] == 4 # 4 discrete actions
|
||||||
|
|
||||||
|
|
||||||
|
def test_onnx_output_matches_sb3(exported_onnx):
|
||||||
|
"""Sanity: ONNX inference for 100 random obs must argmax to the same action as SB3."""
|
||||||
|
import onnxruntime
|
||||||
|
from stable_baselines3 import PPO
|
||||||
|
|
||||||
|
model = PPO.load(str(CHECKPOINT))
|
||||||
|
session = onnxruntime.InferenceSession(str(exported_onnx))
|
||||||
|
input_name = session.get_inputs()[0].name
|
||||||
|
|
||||||
|
rng = np.random.default_rng(1234)
|
||||||
|
obs_batch = rng.normal(size=(100, 7)).astype(np.float32)
|
||||||
|
|
||||||
|
# ONNX: (100, 4) logits
|
||||||
|
onnx_logits = session.run(None, {input_name: obs_batch})[0]
|
||||||
|
onnx_actions = onnx_logits.argmax(axis=-1)
|
||||||
|
|
||||||
|
# SB3 deterministic prediction
|
||||||
|
sb3_actions, _ = model.predict(obs_batch, deterministic=True)
|
||||||
|
|
||||||
|
mismatches = int((onnx_actions != sb3_actions).sum())
|
||||||
|
# Allow up to 2 mismatches out of 100 for float rounding on marginal states.
|
||||||
|
assert mismatches <= 2, f"{mismatches}/100 argmax mismatches between ONNX and SB3"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd Training && pytest tests/test_export_onnx.py && cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: fail because `export_onnx.py` does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write `Training/export_onnx.py`**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Export a trained SB3 PPO policy to ONNX for the .NET backend.
|
||||||
|
|
||||||
|
The exported model has a single float input of shape [batch, 7] (the observation
|
||||||
|
vector) and a single float output of shape [batch, 4] (action logits). Argmax
|
||||||
|
over the last axis gives the action.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import torch
|
||||||
|
from stable_baselines3 import PPO
|
||||||
|
|
||||||
|
|
||||||
|
class OnnxablePolicy(torch.nn.Module):
|
||||||
|
"""Wraps the SB3 policy's actor path for ONNX export.
|
||||||
|
|
||||||
|
Standard SB3 MlpPolicy for a discrete action space:
|
||||||
|
features = policy.extract_features(obs)
|
||||||
|
latent_pi = policy.mlp_extractor.forward_actor(features)
|
||||||
|
logits = policy.action_net(latent_pi)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, policy: torch.nn.Module) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.policy = policy
|
||||||
|
|
||||||
|
def forward(self, obs: torch.Tensor) -> torch.Tensor:
|
||||||
|
features = self.policy.extract_features(obs)
|
||||||
|
latent_pi = self.policy.mlp_extractor.forward_actor(features)
|
||||||
|
return self.policy.action_net(latent_pi)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--checkpoint", type=Path, required=True,
|
||||||
|
help="path to SB3 .zip checkpoint")
|
||||||
|
parser.add_argument("--out", type=Path, required=True,
|
||||||
|
help="destination .onnx path")
|
||||||
|
parser.add_argument("--opset", type=int, default=17)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
model = PPO.load(str(args.checkpoint), device="cpu")
|
||||||
|
model.policy.eval()
|
||||||
|
|
||||||
|
onnxable = OnnxablePolicy(model.policy)
|
||||||
|
onnxable.eval()
|
||||||
|
|
||||||
|
dummy = torch.randn(1, 7, dtype=torch.float32)
|
||||||
|
torch.onnx.export(
|
||||||
|
onnxable,
|
||||||
|
dummy,
|
||||||
|
str(args.out),
|
||||||
|
input_names=["obs"],
|
||||||
|
output_names=["logits"],
|
||||||
|
dynamic_axes={"obs": {0: "batch"}, "logits": {0: "batch"}},
|
||||||
|
opset_version=args.opset,
|
||||||
|
)
|
||||||
|
print(f"exported ONNX policy to {args.out}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd Training && pytest tests/test_export_onnx.py && cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 3 passed. The `test_onnx_output_matches_sb3` test may occasionally show 1-2 argmax mismatches on marginal states — that's why the tolerance is `mismatches <= 2`. If it exceeds 2, something is wrong with the export.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify the produced ONNX file**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -la models/ppo_lander.onnx
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: file exists, non-zero size.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add Training/export_onnx.py Training/tests/test_export_onnx.py
|
||||||
|
git -c user.email=meelstorm@gmail.com -c user.name=meelstorm commit -m "feat(training): export SB3 PPO policy to ONNX with parity test"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: End-to-end pipeline smoke
|
||||||
|
|
||||||
|
**Files:** (no new files)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Full pipeline dry-run**
|
||||||
|
|
||||||
|
Wipes state and re-runs the entire pipeline to prove it works end-to-end.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf checkpoints/ models/
|
||||||
|
dotnet publish GameCli/GameCli.csproj -c Release -o publish/GameCli
|
||||||
|
source .venv/bin/activate
|
||||||
|
cd Training
|
||||||
|
python train.py --steps 10000 --n-envs 2 --use-dummy --max-episode-steps 200
|
||||||
|
python export_onnx.py --checkpoint ../checkpoints/ppo_lander_final.zip --out ../models/ppo_lander.onnx
|
||||||
|
cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- Training completes without error.
|
||||||
|
- `checkpoints/ppo_lander_final.zip` exists.
|
||||||
|
- `models/ppo_lander.onnx` exists and is non-empty.
|
||||||
|
- Exit code 0.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Full test suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd Training && pytest && cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 11 passed, 0 failed (8 env + 3 export).
|
||||||
|
|
||||||
|
- [ ] **Step 3: No commit — this task is verification only**
|
||||||
|
|
||||||
|
If everything passed, Plan 2 is done. If not, fix whatever failed and iterate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of done for Plan 2
|
||||||
|
|
||||||
|
- `cd Training && pytest` reports 11 passed, 0 failed.
|
||||||
|
- Running `python Training/train.py --steps 10000 --n-envs 2 --use-dummy` produces a checkpoint under `checkpoints/`.
|
||||||
|
- Running `python Training/export_onnx.py --checkpoint checkpoints/ppo_lander_final.zip --out models/ppo_lander.onnx` produces a non-empty ONNX file.
|
||||||
|
- The exported ONNX file's argmax action matches SB3's `predict(deterministic=True)` on ≥98% of random observations.
|
||||||
|
|
||||||
|
The next plan (Plan 3) will build the ASP.NET backend that loads this ONNX file and drives the CLI at 50 Hz per connected browser.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes on real training
|
||||||
|
|
||||||
|
The smoke-run steps use tiny values (10k steps, 2 envs) to prove the pipeline works quickly during implementation. To actually train a useful policy, the spec calls for ~2M steps on 8 envs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python Training/train.py --steps 2000000 --n-envs 8
|
||||||
|
```
|
||||||
|
|
||||||
|
This takes on the order of hours on a modern CPU. It's out of scope for Plan 2 (which is about building the pipeline); do it before Plan 3 needs a real model, or use a smoke-trained model to prototype the backend and swap in the real one later.
|
||||||
1095
docs/superpowers/plans/2026-07-17-03-backend.md
Normal file
1095
docs/superpowers/plans/2026-07-17-03-backend.md
Normal file
File diff suppressed because it is too large
Load Diff
825
docs/superpowers/plans/2026-07-17-04-frontend.md
Normal file
825
docs/superpowers/plans/2026-07-17-04-frontend.md
Normal file
@@ -0,0 +1,825 @@
|
|||||||
|
# React Frontend 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 browser-side of the design: a React app that opens a WebSocket to `ws://localhost:5100/ws/game`, streams mouse-position updates to the backend at ~50 Hz, receives ship state each tick, and renders it on an HTML canvas. Reference the design spec at `docs/superpowers/specs/2026-07-17-cursor-following-lander-design.md` §4.
|
||||||
|
|
||||||
|
**Architecture:** Vite + React + TypeScript. Three focused hooks (`useGameSocket`, `useCursorSender`, `useAnimationLoop`) + one canvas component. No routing, no state management library, no game engine — just the platform.
|
||||||
|
|
||||||
|
**Tech Stack:** Node.js 20+, Vite, React 18+, TypeScript. Runtime browser API only (`WebSocket`, `Canvas 2D`, `requestAnimationFrame`, `MouseEvent`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Node.js 20+** available as `node`. Verify with `node --version`.
|
||||||
|
- **Plan 3 backend** works — this frontend expects `ws://localhost:5100/ws/game` to reply. That backend must be startable on demand for manual smoke.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
```
|
||||||
|
Experiment_ReinforcementLearning/
|
||||||
|
├── Frontend/
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── tsconfig.json
|
||||||
|
│ ├── tsconfig.node.json
|
||||||
|
│ ├── vite.config.ts
|
||||||
|
│ ├── index.html
|
||||||
|
│ └── src/
|
||||||
|
│ ├── main.tsx
|
||||||
|
│ ├── App.tsx
|
||||||
|
│ ├── LanderCanvas.tsx
|
||||||
|
│ ├── hooks/
|
||||||
|
│ │ ├── useGameSocket.ts
|
||||||
|
│ │ ├── useCursorSender.ts
|
||||||
|
│ │ └── useAnimationLoop.ts
|
||||||
|
│ ├── protocol.ts # TS types for wire messages
|
||||||
|
│ └── render.ts # pure canvas draw functions
|
||||||
|
```
|
||||||
|
|
||||||
|
Each hook has one responsibility. `render.ts` is pure functions — testable without a DOM.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Scaffold the Vite + React + TypeScript project
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `Frontend/package.json` (via Vite template)
|
||||||
|
- Create: `Frontend/vite.config.ts`
|
||||||
|
- Create: `Frontend/tsconfig.json`
|
||||||
|
- Create: `Frontend/index.html`
|
||||||
|
- Create: `Frontend/src/main.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Check Node is available**
|
||||||
|
|
||||||
|
Run: `node --version`
|
||||||
|
Expected: `v20.x` or higher. If missing, STOP and report BLOCKED.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Scaffold with Vite**
|
||||||
|
|
||||||
|
Run from the repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm create vite@latest Frontend -- --template react-ts
|
||||||
|
```
|
||||||
|
|
||||||
|
This may prompt "Ok to proceed? (y)" — pass `--yes` or reply `y`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Install dependencies**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd Frontend
|
||||||
|
npm install
|
||||||
|
cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `Frontend/node_modules/` created, no errors.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Clean out template placeholders**
|
||||||
|
|
||||||
|
The Vite template creates a demo counter component. Delete files we won't use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -f Frontend/src/App.css
|
||||||
|
rm -f Frontend/src/index.css
|
||||||
|
rm -f Frontend/src/assets/react.svg
|
||||||
|
rm -f Frontend/public/vite.svg
|
||||||
|
```
|
||||||
|
|
||||||
|
Overwrite `Frontend/src/App.tsx` with a placeholder (Task 6 rewrites it):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export function App() {
|
||||||
|
return <div style={{ padding: 20 }}>Frontend placeholder</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Overwrite `Frontend/src/main.tsx` (removing CSS imports the template added):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { App } from './App';
|
||||||
|
|
||||||
|
const root = document.getElementById('root')!;
|
||||||
|
createRoot(root).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Overwrite `Frontend/index.html`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Cursor-Following Lander</title>
|
||||||
|
<style>
|
||||||
|
html, body, #root { margin: 0; padding: 0; height: 100%; overflow: hidden; background: #000; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #eee; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Configure Vite dev proxy for the backend WebSocket**
|
||||||
|
|
||||||
|
Overwrite `Frontend/vite.config.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/ws': {
|
||||||
|
target: 'ws://localhost:5100',
|
||||||
|
ws: true,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
This lets the frontend reach the backend via `ws://localhost:5173/ws/game` (same origin as the dev server), which Vite forwards to `localhost:5100`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Verify it builds and serves**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd Frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `dist/` directory created, no TypeScript errors.
|
||||||
|
|
||||||
|
Dev server smoke (in background):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev &
|
||||||
|
DEV_PID=$!
|
||||||
|
sleep 3
|
||||||
|
curl -sf http://localhost:5173/ | head -5
|
||||||
|
kill $DEV_PID
|
||||||
|
wait $DEV_PID 2>/dev/null
|
||||||
|
cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: the served HTML contains `<div id="root">` and the placeholder script tag.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add Frontend/package.json Frontend/package-lock.json Frontend/vite.config.ts \
|
||||||
|
Frontend/tsconfig.json Frontend/tsconfig.node.json Frontend/tsconfig.app.json \
|
||||||
|
Frontend/index.html Frontend/src/
|
||||||
|
git -c user.email=meelstorm@gmail.com -c user.name=meelstorm commit -m "feat(frontend): scaffold Vite + React + TypeScript project"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `protocol.ts` — wire message TypeScript types
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `Frontend/src/protocol.ts`
|
||||||
|
|
||||||
|
Mirror the backend's WireMessages exactly. This is the source of truth for what the frontend expects on the wire.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write `Frontend/src/protocol.ts`**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Wire messages exchanged with the backend over WebSocket.
|
||||||
|
// Mirror of Backend/WireMessages.cs.
|
||||||
|
|
||||||
|
export interface CursorMessage {
|
||||||
|
type: 'cursor';
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InitFrame {
|
||||||
|
type: 'init';
|
||||||
|
world: [number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StateFrame {
|
||||||
|
type: 'state';
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
angle: number;
|
||||||
|
engine: number;
|
||||||
|
target: [number, number];
|
||||||
|
step: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerFrame = InitFrame | StateFrame;
|
||||||
|
|
||||||
|
export function isInit(frame: ServerFrame): frame is InitFrame {
|
||||||
|
return frame.type === 'init';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isState(frame: ServerFrame): frame is StateFrame {
|
||||||
|
return frame.type === 'state';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify TypeScript compiles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd Frontend && npx tsc --noEmit && cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add Frontend/src/protocol.ts
|
||||||
|
git -c user.email=meelstorm@gmail.com -c user.name=meelstorm commit -m "feat(frontend): wire message TypeScript types"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: `useGameSocket` hook
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `Frontend/src/hooks/useGameSocket.ts`
|
||||||
|
|
||||||
|
Opens the WebSocket on mount, parses inbound frames, exposes `{init, state, connected, send}` as React state.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write `Frontend/src/hooks/useGameSocket.ts`**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import type { InitFrame, ServerFrame, StateFrame, CursorMessage } from '../protocol';
|
||||||
|
import { isInit, isState } from '../protocol';
|
||||||
|
|
||||||
|
export interface GameSocketState {
|
||||||
|
connected: boolean;
|
||||||
|
init: InitFrame | null;
|
||||||
|
state: StateFrame | null;
|
||||||
|
sendCursor: (x: number, y: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a WebSocket to the given URL, tracks the latest init/state frames.
|
||||||
|
* Auto-reconnects with exponential backoff on close.
|
||||||
|
*/
|
||||||
|
export function useGameSocket(url: string): GameSocketState {
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [init, setInit] = useState<InitFrame | null>(null);
|
||||||
|
const [state, setState] = useState<StateFrame | null>(null);
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let closed = false;
|
||||||
|
let backoffMs = 500;
|
||||||
|
let reconnectTimer: number | null = null;
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
setConnected(true);
|
||||||
|
backoffMs = 500; // reset backoff on successful connect
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
setConnected(false);
|
||||||
|
wsRef.current = null;
|
||||||
|
if (!closed) {
|
||||||
|
reconnectTimer = window.setTimeout(open, backoffMs);
|
||||||
|
backoffMs = Math.min(backoffMs * 2, 8000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onerror = () => { /* let onclose handle it */ };
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
let frame: ServerFrame;
|
||||||
|
try {
|
||||||
|
frame = JSON.parse(ev.data) as ServerFrame;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isInit(frame)) setInit(frame);
|
||||||
|
else if (isState(frame)) setState(frame);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
open();
|
||||||
|
return () => {
|
||||||
|
closed = true;
|
||||||
|
if (reconnectTimer !== null) window.clearTimeout(reconnectTimer);
|
||||||
|
wsRef.current?.close();
|
||||||
|
};
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
const sendCursor = (x: number, y: number) => {
|
||||||
|
const ws = wsRef.current;
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
const msg: CursorMessage = { type: 'cursor', x, y };
|
||||||
|
ws.send(JSON.stringify(msg));
|
||||||
|
};
|
||||||
|
|
||||||
|
return { connected, init, state, sendCursor };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify TypeScript compiles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd Frontend && npx tsc --noEmit && cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add Frontend/src/hooks/useGameSocket.ts
|
||||||
|
git -c user.email=meelstorm@gmail.com -c user.name=meelstorm commit -m "feat(frontend): useGameSocket hook with auto-reconnect"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: `useCursorSender` hook
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `Frontend/src/hooks/useCursorSender.ts`
|
||||||
|
|
||||||
|
Attaches a `mousemove` listener to a target element (the canvas). Converts pixel coords → world coords in `[0,1] × [0,1]` using letterbox mapping. Throttles sends to ~50 Hz using a `requestAnimationFrame` timestamp gate.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write `Frontend/src/hooks/useCursorSender.ts`**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a viewport point to world coords in [0,1] × [0,1] using
|
||||||
|
* letterbox mapping (preserves world aspect ratio 1:1).
|
||||||
|
*/
|
||||||
|
export function viewportToWorld(
|
||||||
|
viewportPx: { x: number; y: number },
|
||||||
|
viewportSize: { w: number; h: number },
|
||||||
|
): { x: number; y: number } {
|
||||||
|
// World is 1×1. Fit inside viewport with black bars on the wider axis.
|
||||||
|
const scale = Math.min(viewportSize.w, viewportSize.h);
|
||||||
|
const offsetX = (viewportSize.w - scale) / 2;
|
||||||
|
const offsetY = (viewportSize.h - scale) / 2;
|
||||||
|
return {
|
||||||
|
x: Math.max(0, Math.min(1, (viewportPx.x - offsetX) / scale)),
|
||||||
|
y: Math.max(0, Math.min(1, (viewportPx.y - offsetY) / scale)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEND_INTERVAL_MS = 20; // 50 Hz cap
|
||||||
|
|
||||||
|
export function useCursorSender(
|
||||||
|
targetRef: React.RefObject<HTMLElement>,
|
||||||
|
onSend: (x: number, y: number) => void,
|
||||||
|
) {
|
||||||
|
const lastCursorRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
const lastSentAtRef = useRef<number>(0);
|
||||||
|
const rafRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = targetRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const handleMove = (ev: MouseEvent) => {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const world = viewportToWorld(
|
||||||
|
{ x: ev.clientX - rect.left, y: ev.clientY - rect.top },
|
||||||
|
{ w: rect.width, h: rect.height },
|
||||||
|
);
|
||||||
|
lastCursorRef.current = world;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const c = lastCursorRef.current;
|
||||||
|
if (c && now - lastSentAtRef.current >= SEND_INTERVAL_MS) {
|
||||||
|
onSend(c.x, c.y);
|
||||||
|
lastSentAtRef.current = now;
|
||||||
|
}
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
|
||||||
|
el.addEventListener('mousemove', handleMove);
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('mousemove', handleMove);
|
||||||
|
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
|
||||||
|
};
|
||||||
|
}, [targetRef, onSend]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify TypeScript compiles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd Frontend && npx tsc --noEmit && cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add Frontend/src/hooks/useCursorSender.ts
|
||||||
|
git -c user.email=meelstorm@gmail.com -c user.name=meelstorm commit -m "feat(frontend): useCursorSender hook with letterbox mapping and 50 Hz throttle"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: `render.ts` — pure canvas draw functions
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `Frontend/src/render.ts`
|
||||||
|
|
||||||
|
Pure functions that draw the scene on a `CanvasRenderingContext2D`. Testable without a DOM (Vitest is not being set up in this plan — this is more about keeping the module isolated for correctness).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write `Frontend/src/render.ts`**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { StateFrame } from './protocol';
|
||||||
|
|
||||||
|
/** Ship as a filled triangle, drawn at (x,y) rotated by angle. Size in canvas px. */
|
||||||
|
export function drawShip(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
worldX: number,
|
||||||
|
worldY: number,
|
||||||
|
angle: number,
|
||||||
|
engine: number,
|
||||||
|
scale: number,
|
||||||
|
offsetX: number,
|
||||||
|
offsetY: number,
|
||||||
|
) {
|
||||||
|
const px = offsetX + worldX * scale;
|
||||||
|
const py = offsetY + worldY * scale;
|
||||||
|
const size = scale * 0.04;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(px, py);
|
||||||
|
ctx.rotate(angle);
|
||||||
|
|
||||||
|
// Body: triangle pointing up (angle=0 → up).
|
||||||
|
ctx.fillStyle = '#eee';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, -size);
|
||||||
|
ctx.lineTo(size * 0.7, size * 0.6);
|
||||||
|
ctx.lineTo(-size * 0.7, size * 0.6);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Flame if an engine is firing.
|
||||||
|
if (engine !== 0) {
|
||||||
|
ctx.fillStyle = '#ff9d3d';
|
||||||
|
ctx.beginPath();
|
||||||
|
if (engine === 2) {
|
||||||
|
// Main engine: flame under the ship.
|
||||||
|
const flame = size * 0.9 + Math.random() * size * 0.4;
|
||||||
|
ctx.moveTo(-size * 0.4, size * 0.6);
|
||||||
|
ctx.lineTo(size * 0.4, size * 0.6);
|
||||||
|
ctx.lineTo(0, size * 0.6 + flame);
|
||||||
|
} else if (engine === 1) {
|
||||||
|
// Left thruster: flame on right side of body.
|
||||||
|
ctx.moveTo(size * 0.7, -size * 0.2);
|
||||||
|
ctx.lineTo(size * 0.7, size * 0.2);
|
||||||
|
ctx.lineTo(size * 1.3, 0);
|
||||||
|
} else if (engine === 3) {
|
||||||
|
// Right thruster: flame on left side of body.
|
||||||
|
ctx.moveTo(-size * 0.7, -size * 0.2);
|
||||||
|
ctx.lineTo(-size * 0.7, size * 0.2);
|
||||||
|
ctx.lineTo(-size * 1.3, 0);
|
||||||
|
}
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draw a crosshair at the target world position. */
|
||||||
|
export function drawTarget(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
worldX: number,
|
||||||
|
worldY: number,
|
||||||
|
scale: number,
|
||||||
|
offsetX: number,
|
||||||
|
offsetY: number,
|
||||||
|
) {
|
||||||
|
const px = offsetX + worldX * scale;
|
||||||
|
const py = offsetY + worldY * scale;
|
||||||
|
const r = 8;
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#5cf';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px, py, r, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(px - r * 1.5, py);
|
||||||
|
ctx.lineTo(px + r * 1.5, py);
|
||||||
|
ctx.moveTo(px, py - r * 1.5);
|
||||||
|
ctx.lineTo(px, py + r * 1.5);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Layout: fit the 1×1 world into the canvas with black letterbox bars. */
|
||||||
|
export function layout(canvas: HTMLCanvasElement) {
|
||||||
|
const scale = Math.min(canvas.width, canvas.height);
|
||||||
|
const offsetX = (canvas.width - scale) / 2;
|
||||||
|
const offsetY = (canvas.height - scale) / 2;
|
||||||
|
return { scale, offsetX, offsetY };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render one frame: clear + world background + target + ship. */
|
||||||
|
export function renderFrame(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
state: StateFrame | null,
|
||||||
|
) {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
// Clear
|
||||||
|
ctx.fillStyle = '#000';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
const { scale, offsetX, offsetY } = layout(canvas);
|
||||||
|
|
||||||
|
// World background (subtle dark band so the play area is visible).
|
||||||
|
ctx.fillStyle = '#0a0a12';
|
||||||
|
ctx.fillRect(offsetX, offsetY, scale, scale);
|
||||||
|
|
||||||
|
if (!state) return;
|
||||||
|
|
||||||
|
drawTarget(ctx, state.target[0], state.target[1], scale, offsetX, offsetY);
|
||||||
|
drawShip(ctx, state.x, state.y, state.angle, state.engine, scale, offsetX, offsetY);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify TypeScript compiles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd Frontend && npx tsc --noEmit && cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add Frontend/src/render.ts
|
||||||
|
git -c user.email=meelstorm@gmail.com -c user.name=meelstorm commit -m "feat(frontend): pure canvas draw functions (ship + flame + target + layout)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: `useAnimationLoop`, `LanderCanvas`, `App` — wire it all up
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `Frontend/src/hooks/useAnimationLoop.ts`
|
||||||
|
- Create: `Frontend/src/LanderCanvas.tsx`
|
||||||
|
- Modify: `Frontend/src/App.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write `Frontend/src/hooks/useAnimationLoop.ts`**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
/** Calls `callback` once per browser frame. Runs while mounted. */
|
||||||
|
export function useAnimationLoop(callback: (now: number) => void) {
|
||||||
|
const cbRef = useRef(callback);
|
||||||
|
cbRef.current = callback;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let rafId = 0;
|
||||||
|
const loop = (now: number) => {
|
||||||
|
cbRef.current(now);
|
||||||
|
rafId = requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
rafId = requestAnimationFrame(loop);
|
||||||
|
return () => cancelAnimationFrame(rafId);
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write `Frontend/src/LanderCanvas.tsx`**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import type { StateFrame } from './protocol';
|
||||||
|
import { renderFrame } from './render';
|
||||||
|
import { useAnimationLoop } from './hooks/useAnimationLoop';
|
||||||
|
import { useCursorSender } from './hooks/useCursorSender';
|
||||||
|
|
||||||
|
export interface LanderCanvasProps {
|
||||||
|
state: StateFrame | null;
|
||||||
|
onCursor: (x: number, y: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-viewport canvas. Redraws every animation frame reading the latest
|
||||||
|
* `state` prop (no interpolation). Mouse movement is captured on the canvas
|
||||||
|
* and forwarded through `onCursor`.
|
||||||
|
*/
|
||||||
|
export function LanderCanvas({ state, onCursor }: LanderCanvasProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const stateRef = useRef<StateFrame | null>(state);
|
||||||
|
stateRef.current = state;
|
||||||
|
|
||||||
|
// Keep the canvas's backing store in sync with its CSS size + devicePixelRatio.
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
const dpr = window.devicePixelRatio ?? 1;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
canvas.width = Math.floor(rect.width * dpr);
|
||||||
|
canvas.height = Math.floor(rect.height * dpr);
|
||||||
|
};
|
||||||
|
resize();
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
return () => window.removeEventListener('resize', resize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useAnimationLoop(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
renderFrame(canvas, stateRef.current);
|
||||||
|
});
|
||||||
|
|
||||||
|
useCursorSender(canvasRef as React.RefObject<HTMLElement>, onCursor);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
style={{ display: 'block', width: '100vw', height: '100vh', cursor: 'crosshair' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Overwrite `Frontend/src/App.tsx`**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useGameSocket } from './hooks/useGameSocket';
|
||||||
|
import { LanderCanvas } from './LanderCanvas';
|
||||||
|
|
||||||
|
// Vite dev server proxies `/ws` → `ws://localhost:5100`. In prod the backend
|
||||||
|
// serves the built static files, so same-origin works there too.
|
||||||
|
function buildWsUrl(): string {
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
return `${proto}//${window.location.host}/ws/game`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const { connected, state, sendCursor } = useGameSocket(buildWsUrl());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LanderCanvas state={state} onCursor={sendCursor} />
|
||||||
|
<StatusBar connected={connected} step={state?.step ?? 0} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusBarProps { connected: boolean; step: number; }
|
||||||
|
function StatusBar({ connected, step }: StatusBarProps) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', top: 8, left: 8, padding: '4px 10px',
|
||||||
|
background: 'rgba(0,0,0,0.5)', borderRadius: 4, fontSize: 12,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}>
|
||||||
|
<span style={{ color: connected ? '#5f5' : '#f55' }}>●</span>{' '}
|
||||||
|
{connected ? 'connected' : 'disconnected'} · step {step}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify TypeScript compiles and build passes**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd Frontend && npx tsc --noEmit && npm run build && cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no TypeScript errors, `dist/` produced.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add Frontend/src/hooks/useAnimationLoop.ts Frontend/src/LanderCanvas.tsx Frontend/src/App.tsx
|
||||||
|
git -c user.email=meelstorm@gmail.com -c user.name=meelstorm commit -m "feat(frontend): LanderCanvas + App wiring cursor and state to WebSocket"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: End-to-end manual verification
|
||||||
|
|
||||||
|
**Files:** (none — verification only)
|
||||||
|
|
||||||
|
This task launches everything at once (backend + frontend) and confirms the ship renders and moves.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Kill any stale processes**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pkill -f "dotnet.*Backend" 2>/dev/null; sleep 1
|
||||||
|
pkill -f "vite" 2>/dev/null; sleep 1
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Publish CLI (idempotent)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet publish GameCli/GameCli.csproj -c Release -o publish/GameCli
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Start the backend in the background**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ASPNETCORE_URLS=http://localhost:5100 dotnet run --project Backend --no-launch-profile > /tmp/backend.log 2>&1 &
|
||||||
|
BACKEND_PID=$!
|
||||||
|
sleep 5
|
||||||
|
grep -q "Loaded PPO policy" /tmp/backend.log && echo "backend loaded ONNX" || (echo "backend startup failed"; cat /tmp/backend.log; exit 1)
|
||||||
|
curl -sf http://localhost:5100/ && echo
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `backend loaded ONNX` and health returns `GameCli Backend`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Start the Vite dev server**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd Frontend
|
||||||
|
npm run dev > /tmp/vite.log 2>&1 &
|
||||||
|
VITE_PID=$!
|
||||||
|
cd ..
|
||||||
|
sleep 5
|
||||||
|
curl -sf http://localhost:5173/ | grep -q "Cursor-Following Lander" && echo "vite serving index"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `vite serving index`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: WebSocket handshake test through Vite's proxy**
|
||||||
|
|
||||||
|
Use a small Node script or curl to prove the WS proxies through. Node one-liner (assumes `ws` is available; if not, skip this step and rely on the browser check below).
|
||||||
|
|
||||||
|
Simpler: just use `curl` to hit the frontend and confirm no 500 errors. The real WS test is the browser check.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Browser check (manual — the deliverable)**
|
||||||
|
|
||||||
|
Open `http://localhost:5173/` in a browser. You should see:
|
||||||
|
- Black background with a slightly-lighter square (the world).
|
||||||
|
- A blue crosshair follows your mouse.
|
||||||
|
- A small white triangular ship appears somewhere in the world, twitching / firing engines as the (smoke-trained) PPO policy tries to control it.
|
||||||
|
- Top-left corner shows `● connected · step N` with N ticking up.
|
||||||
|
|
||||||
|
If the ship never appears or the socket says `disconnected`, capture what's on screen and the browser DevTools Network → WS tab for the `ws/game` connection to diagnose.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Teardown**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kill $VITE_PID $BACKEND_PID 2>/dev/null
|
||||||
|
wait $VITE_PID $BACKEND_PID 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: No commit — verification only**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of done for Plan 4
|
||||||
|
|
||||||
|
- `npx tsc --noEmit` in `Frontend/` reports 0 errors.
|
||||||
|
- `npm run build` produces `Frontend/dist/`.
|
||||||
|
- With backend running on 5100 and Vite dev server on 5173, opening `http://localhost:5173/` shows a canvas with a ship that responds to the cursor.
|
||||||
|
- Every source file has one clear responsibility; no file exceeds ~150 lines.
|
||||||
|
|
||||||
|
## Notes on real training vs the smoke model
|
||||||
|
|
||||||
|
The ship's motion quality directly reflects how well-trained the PPO policy is. The current `models/ppo_lander.onnx` was trained for only 10k steps in Plan 2 — it's essentially random with a slight bias. To see actual cursor-following behavior:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate
|
||||||
|
python Training/train.py --steps 2000000 --n-envs 8
|
||||||
|
python Training/export_onnx.py \
|
||||||
|
--checkpoint checkpoints/ppo_lander_final.zip \
|
||||||
|
--out models/ppo_lander.onnx
|
||||||
|
```
|
||||||
|
|
||||||
|
Then restart the backend so it re-loads the new ONNX. Training takes on the order of an hour on CPU.
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
# Cursor-Following Lunar Lander — Design
|
||||||
|
|
||||||
|
**Date:** 2026-07-17
|
||||||
|
**Status:** Approved (ready for implementation planning)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Build a cursor-following lunar lander demo trained with PPO. A user opens a web page, moves the mouse, and a rocket ship — controlled by a trained neural network policy — chases the cursor by firing its main engine and side thrusters, fighting gravity as it goes.
|
||||||
|
|
||||||
|
The organizing constraint: **strict separation between game logic and UI.** The game engine is a standalone CLI process. It knows nothing about UIs, HTTP, or PPO. The exact same CLI is used both as the training environment for the Python PPO trainer and as the physics core the ASP.NET backend drives at runtime. Training physics and inference physics are identical by construction.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No terrain, no ground, no landing pad, no touchdown detection. The task is continuous cursor-tracking; the ship never lands.
|
||||||
|
- No user "control modes" (no keyboard control, no scripted controller, no idle mode). At runtime the ship is always driven by the trained PPO policy trying to reach the cursor. In training, the Python trainer drives the CLI directly.
|
||||||
|
- No multi-player, no accounts, no persistence.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Four processes, communicating over narrow interfaces:
|
||||||
|
|
||||||
|
```
|
||||||
|
[React UI] ──WebSocket──▶ [ASP.NET backend] ──stdin/stdout──▶ [Game CLI (.NET)]
|
||||||
|
▲
|
||||||
|
│ same protocol
|
||||||
|
│
|
||||||
|
[Python PPO trainer] ──┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Game CLI (C#/.NET):** stateless step-driven executable. Reads one action per line on stdin, writes one observation JSON per line on stdout. Owns physics.
|
||||||
|
- **ASP.NET backend:** owns one long-lived WebSocket per player. On connect, spawns one Game CLI subprocess and loads the trained PPO ONNX model. Each tick: reads latest cursor, runs the policy, steps the CLI, forwards state to the browser.
|
||||||
|
- **React frontend:** captures the mouse position, sends it to the backend, receives ship state, renders it on a canvas.
|
||||||
|
- **Python PPO trainer:** wraps the same Game CLI as a `gym.Env`, trains a PPO policy with stable-baselines3, exports the trained network to ONNX for the backend to load.
|
||||||
|
|
||||||
|
## Game CLI
|
||||||
|
|
||||||
|
### Protocol
|
||||||
|
|
||||||
|
Line-delimited JSON over stdio. One line in → one physics step → one line out.
|
||||||
|
|
||||||
|
**Startup handshake** (CLI emits one line on start):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"init": {"world": [1.0, 1.0], "dt": 0.02, "obs_dim": 7, "n_actions": 4}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step input** (client writes one line):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"action": 2, "target": [0.35, 0.60]}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `action ∈ {0: noop, 1: left thruster, 2: main engine, 3: right thruster}`
|
||||||
|
- `target` — current goal position in world coords, updated every step.
|
||||||
|
|
||||||
|
**Step output** (CLI writes one line):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"obs": [dx, dy, vx, vy, sin_theta, cos_theta, omega],
|
||||||
|
"state": {"x": 0.51, "y": 0.63, "angle": -0.12, "engine": 2},
|
||||||
|
"reward": -0.42,
|
||||||
|
"done": false,
|
||||||
|
"step": 137
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `obs` — 7-dim vector fed to PPO. `dx = target.x − ship.x`, `dy = target.y − ship.y`. Angle is encoded as `(sin, cos)` to avoid wrap discontinuity. `omega` is angular velocity.
|
||||||
|
- `state` — raw pose the UI needs to render. Redundant with `obs`, but rendering shouldn't have to un-normalize.
|
||||||
|
- `reward`, `done`, `step` — for the trainer; UI ignores them.
|
||||||
|
|
||||||
|
**Reset command** (client writes one line):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"cmd": "reset", "seed": 12345}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reinitializes ship to a random start pose (using `seed` if provided), clears step counter, emits a fresh observation line.
|
||||||
|
|
||||||
|
### Physics
|
||||||
|
|
||||||
|
- World is `[0, 1] × [0, 1]`. No walls; the ship can drift off-screen and is pulled back by the distance-to-cursor reward term.
|
||||||
|
- Constant downward gravity `g` (acceleration).
|
||||||
|
- **Main engine (action 2):** thrust force along `+body-up`, rotated by the ship's angle.
|
||||||
|
- **Left / right thruster (actions 1 and 3):** apply torque (to rotate the ship) and a small lateral impulse.
|
||||||
|
- Explicit Euler integration at fixed `dt = 0.02` (50 Hz).
|
||||||
|
- Constants (gravity, thrust magnitude, torque, drag, engine noise) live in a single `Physics.cs` with named fields for easy tuning.
|
||||||
|
|
||||||
|
### Reward
|
||||||
|
|
||||||
|
Per step:
|
||||||
|
|
||||||
|
```
|
||||||
|
r = -‖(dx, dy)‖ // distance-to-cursor (dominant)
|
||||||
|
− lambda_v · ‖(vx, vy)‖ // velocity penalty
|
||||||
|
− lambda_theta · |angle| // upright penalty
|
||||||
|
− lambda_fuel · engine_on // fuel penalty
|
||||||
|
```
|
||||||
|
|
||||||
|
Initial coefficients: `lambda_v = 0.1`, `lambda_theta = 0.1`, `lambda_fuel = 0.03`. All in `Physics.cs`, tunable.
|
||||||
|
|
||||||
|
### Termination
|
||||||
|
|
||||||
|
- `done` is never set on distance or angle; the hover task is continuous.
|
||||||
|
- The trainer imposes a max-steps cap (1000) via truncation.
|
||||||
|
- The CLI honors an explicit `reset` command from either the trainer or the backend.
|
||||||
|
|
||||||
|
## Backend (ASP.NET)
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
- **`GameProcess`** — thin wrapper over `System.Diagnostics.Process` launching the Game CLI. Exposes `Task<Observation> StepAsync(int action, (float x, float y) target)` and `Task ResetAsync()`. Owns stdin/stdout with line-based reader. One instance per player. Disposal kills the process.
|
||||||
|
- **`GameSession`** — one per connected browser. Holds a `GameProcess`, current cursor position (via a `System.Threading.Channels` mailbox), and the last observation. Runs a 50 Hz tick loop:
|
||||||
|
1. Read latest cursor from mailbox.
|
||||||
|
2. Ask `PolicyRunner` for an action given the last obs.
|
||||||
|
3. `await gameProcess.StepAsync(action, cursor)`.
|
||||||
|
4. Push the returned `state` to the WebSocket send queue.
|
||||||
|
- **`GameHub`** — the WebSocket endpoint at `/ws/game`. On connect: create `GameSession`, start its tick loop. On message: parse cursor updates and route to the session. On disconnect: dispose session (kills CLI).
|
||||||
|
- **`PolicyRunner`** — single instance per backend process (inference is stateless; weights are shared across sessions). Loads `models/ppo_lander.onnx` on startup via `Microsoft.ML.OnnxRuntime`. Exposes `int SelectAction(float[7] obs)` that runs one inference and `argmax`es the logits. If the model file is missing at startup, the backend fails fast — there is no fallback.
|
||||||
|
|
||||||
|
### WebSocket wire format
|
||||||
|
|
||||||
|
- **Client → server:**
|
||||||
|
- `{"type": "cursor", "x": 0.42, "y": 0.68}` — sent on `mousemove`, throttled to ~50 Hz on the client.
|
||||||
|
- **Server → client:**
|
||||||
|
- `{"type": "init", "world": [1.0, 1.0]}` — sent once on connect (forwarded from the CLI handshake).
|
||||||
|
- `{"type": "state", "x": ..., "y": ..., "angle": ..., "engine": 2, "target": [...], "step": 137}` — one per tick.
|
||||||
|
|
||||||
|
### Concurrency
|
||||||
|
|
||||||
|
One CLI process per session, one ticker task per session. Sessions are fully independent. N players = N CLI processes = N ticker tasks. Adequate for demo scale.
|
||||||
|
|
||||||
|
## Frontend (React)
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
- **`useGameSocket` hook** — opens the WebSocket, parses incoming messages, exposes `{init, state}` as React state. Reconnects on close. Sends outgoing cursor messages.
|
||||||
|
- **`<LanderCanvas>`** — a `<canvas>` rendered each frame via `requestAnimationFrame`, reading the latest `state` from the socket hook. Draws:
|
||||||
|
- Ship body (simple polygon), rotated by `state.angle`.
|
||||||
|
- A flame under the active engine if `state.engine != 0` (main = downward flare, left thruster = right-side flare, right thruster = left-side flare).
|
||||||
|
- A crosshair at `state.target` (server-echoed cursor, so the user can see the target the server is actually using).
|
||||||
|
- **`useCursorSender` hook** — listens for `mousemove` on the canvas, converts pixel coords to world coords (`[0, 1] × [0, 1]`), sends via the socket. Throttled to ~50 Hz using a `requestAnimationFrame` timestamp gate.
|
||||||
|
- **`<App>`** — full-viewport canvas, wires the hooks, shows a small connection-status indicator.
|
||||||
|
|
||||||
|
### Coordinate mapping
|
||||||
|
|
||||||
|
World is `[0, 1] × [0, 1]`. Canvas dimensions are the viewport. World is **letterboxed** into the viewport (black bars on the wider axis) so the ship never distorts.
|
||||||
|
|
||||||
|
### Rendering
|
||||||
|
|
||||||
|
Plain Canvas 2D, no WebGL, no game engine. The scene is one ship + one dot + one flame. Draw the latest received state directly; no interpolation. Add interpolation later only if visible judder appears.
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
Vite + React + TypeScript. In production, ASP.NET serves the built assets as static files.
|
||||||
|
|
||||||
|
## Training pipeline (Python)
|
||||||
|
|
||||||
|
### `LanderCliEnv(gym.Env)`
|
||||||
|
|
||||||
|
Wraps one C# CLI subprocess as a Gymnasium environment.
|
||||||
|
|
||||||
|
- `__init__`: `Popen` the CLI, read `init` handshake, set `observation_space = Box(-inf, +inf, (7,))`, `action_space = Discrete(4)`.
|
||||||
|
- `reset(seed)`: pick a random target uniformly in `[0, 1] × [0, 1]`, send `{"cmd": "reset", "seed": seed}`, read observation, return it.
|
||||||
|
- `step(action)`: send `{"action": a, "target": [tx, ty]}` (target fixed for the whole episode), read one line, return `(obs, reward, terminated=False, truncated=(step>=1000), info)`.
|
||||||
|
- `close`: close stdin, wait for exit.
|
||||||
|
|
||||||
|
### Training
|
||||||
|
|
||||||
|
- **Vectorized rollout:** `SB3.SubprocVecEnv` with N `LanderCliEnv` instances (N = CPU cores) → N CLI subprocesses.
|
||||||
|
- **Algorithm:** `stable_baselines3.PPO` with `MlpPolicy`, hidden layers `[64, 64]` as a starting point, otherwise stock SB3 hyperparameters.
|
||||||
|
- **Budget:** 2M steps as a first pass. Checkpoint to `checkpoints/`, log to TensorBoard, eval every N steps against a fixed set of eval targets (reproducible seed) for a stable success metric.
|
||||||
|
- **Target sampling for training:** each episode picks a random target once and keeps it fixed. This is simpler than curriculum-based moving targets and generalizes fine at inference, because the policy runs at higher frequency than a cursor moves.
|
||||||
|
|
||||||
|
### ONNX export (`export_onnx.py`)
|
||||||
|
|
||||||
|
- Load the final SB3 checkpoint.
|
||||||
|
- Extract `policy.mlp_extractor` + `policy.action_net` into a single `torch.nn.Module` that takes 7-dim input and returns 4-dim action logits.
|
||||||
|
- `torch.onnx.export(...)` with a fixed opset, dynamic axes disabled (batch=1).
|
||||||
|
- Write to `models/ppo_lander.onnx`.
|
||||||
|
- Verify: load with `onnxruntime`, run on 100 random obs, assert outputs match SB3 within 1e-5.
|
||||||
|
|
||||||
|
## Repo layout
|
||||||
|
|
||||||
|
```
|
||||||
|
GameCli/ # C# .NET console app — the game engine (CLI)
|
||||||
|
Backend/ # ASP.NET Core Web API — WebSocket hub + CLI orchestration + ONNX inference
|
||||||
|
Frontend/ # React + Vite + TypeScript — mouse capture + canvas rendering
|
||||||
|
Training/ # Python — LanderCliEnv, train.py, export_onnx.py, requirements.txt
|
||||||
|
models/ # exported ONNX (gitignored; produced by training)
|
||||||
|
checkpoints/ # SB3 training checkpoints (gitignored)
|
||||||
|
docs/superpowers/specs/ # design docs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing strategy (high level)
|
||||||
|
|
||||||
|
- **`GameCli`:** unit tests for physics integration (deterministic given seed) and reward computation.
|
||||||
|
- **`Backend`:** integration test that spawns the real CLI subprocess and asserts a full tick round-trip.
|
||||||
|
- **`Training`:** smoke test that `LanderCliEnv` passes `check_env` from Gymnasium.
|
||||||
|
- **`Frontend`:** manual smoke — visual check that the ship renders and responds.
|
||||||
|
|
||||||
|
Detailed test cases live in the implementation plan.
|
||||||
Reference in New Issue
Block a user