feat(backend): GameProcess subprocess wrapper with async step/reset
This commit is contained in:
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
{
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user