diff --git a/Backend.Tests/GameProcessTests.cs b/Backend.Tests/GameProcessTests.cs new file mode 100644 index 0000000..fd0e2eb --- /dev/null +++ b/Backend.Tests/GameProcessTests.cs @@ -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); + } +} diff --git a/Backend/GameProcess.cs b/Backend/GameProcess.cs new file mode 100644 index 0000000..f97254a --- /dev/null +++ b/Backend/GameProcess.cs @@ -0,0 +1,166 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Backend; + +/// +/// One instance == one GameCli subprocess. Not thread-safe. +/// Callers must sequence and . +/// +public sealed class GameProcess : IAsyncDisposable +{ + private readonly string _binaryPath; + private Process? _proc; + private StreamReader? _stdout; + private StreamWriter? _stdin; + + public float Dt { get; private set; } + public int ObsDim { get; private set; } + public int NActions { get; private set; } + + public GameProcess(string binaryPath) + { + _binaryPath = binaryPath; + } + + public async Task StartAsync() + { + if (_proc is not null) throw new InvalidOperationException("already started"); + if (!File.Exists(_binaryPath)) + throw new FileNotFoundException($"GameCli binary not found at {_binaryPath}"); + + var psi = new ProcessStartInfo + { + FileName = _binaryPath, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + _proc = Process.Start(psi) + ?? throw new InvalidOperationException("failed to start GameCli"); + _stdout = _proc.StandardOutput; + _stdin = _proc.StandardInput; + + var line = await _stdout.ReadLineAsync() + ?? throw new IOException("GameCli died before init handshake"); + var init = JsonSerializer.Deserialize(line, ProcessJsonContext.Default.InitEnvelope) + ?? throw new IOException($"malformed init handshake: {line}"); + Dt = init.Init.Dt; + ObsDim = init.Init.ObsDim; + NActions = init.Init.NActions; + } + + public async Task StepAsync(int action, (float X, float Y) target) + { + var input = new StepIn + { + Action = action, + Target = new[] { target.X, target.Y }, + }; + await WriteAsync(input, ProcessJsonContext.Default.StepIn); + return await ReadStepResultAsync(); + } + + public async Task ResetAsync(int? seed = null) + { + var input = new StepIn { Cmd = "reset", Seed = seed }; + await WriteAsync(input, ProcessJsonContext.Default.StepIn); + return await ReadStepResultAsync(); + } + + private async Task WriteAsync(T value, System.Text.Json.Serialization.Metadata.JsonTypeInfo ctx) + { + if (_stdin is null) throw new InvalidOperationException("not started"); + var json = JsonSerializer.Serialize(value, ctx); + await _stdin.WriteLineAsync(json); + await _stdin.FlushAsync(); + } + + private async Task ReadStepResultAsync() + { + if (_stdout is null) throw new InvalidOperationException("not started"); + var line = await _stdout.ReadLineAsync() + ?? throw new IOException("GameCli closed stdout unexpectedly"); + var msg = JsonSerializer.Deserialize(line, ProcessJsonContext.Default.StepOut) + ?? throw new IOException($"malformed step output: {line}"); + return new StepResult(msg.Obs, msg.State, msg.Reward, msg.Done, msg.Step); + } + + public async ValueTask DisposeAsync() + { + try { _stdin?.Close(); } catch { } + if (_proc is not null) + { + try + { + var exited = _proc.WaitForExit(2000); + if (!exited) _proc.Kill(entireProcessTree: true); + } + catch { } + _proc.Dispose(); + _proc = null; + } + await ValueTask.CompletedTask; + } +} + +// -------- Result -------- + +public readonly record struct StepResult( + float[] Observation, + ShipStatePayload State, + float Reward, + bool Done, + int Step); + +// -------- Wire DTOs (local mirror of GameCli's Protocol.cs) -------- + +public sealed class StepIn +{ + [JsonPropertyName("action")] public int? Action { get; set; } + [JsonPropertyName("target")] public float[]? Target { get; set; } + [JsonPropertyName("cmd")] public string? Cmd { get; set; } + [JsonPropertyName("seed")] public int? Seed { get; set; } +} + +public sealed class StepOut +{ + [JsonPropertyName("obs")] public float[] Obs { get; set; } = Array.Empty(); + [JsonPropertyName("state")] public ShipStatePayload State { get; set; } = new(); + [JsonPropertyName("reward")] public float Reward { get; set; } + [JsonPropertyName("done")] public bool Done { get; set; } + [JsonPropertyName("step")] public int Step { get; set; } +} + +public sealed class ShipStatePayload +{ + [JsonPropertyName("x")] public float X { get; set; } + [JsonPropertyName("y")] public float Y { get; set; } + [JsonPropertyName("angle")] public float Angle { get; set; } + [JsonPropertyName("engine")] public int Engine { get; set; } +} + +public sealed class InitEnvelope +{ + [JsonPropertyName("init")] public InitPayload Init { get; set; } = new(); +} + +public sealed class InitPayload +{ + [JsonPropertyName("world")] public float[] World { get; set; } = new[] { 1f, 1f }; + [JsonPropertyName("dt")] public float Dt { get; set; } + [JsonPropertyName("obs_dim")] public int ObsDim { get; set; } + [JsonPropertyName("n_actions")] public int NActions { get; set; } +} + +[JsonSourceGenerationOptions(WriteIndented = false)] +[JsonSerializable(typeof(StepIn))] +[JsonSerializable(typeof(StepOut))] +[JsonSerializable(typeof(ShipStatePayload))] +[JsonSerializable(typeof(InitEnvelope))] +[JsonSerializable(typeof(InitPayload))] +internal partial class ProcessJsonContext : JsonSerializerContext +{ +}