feat(backend): GameProcess subprocess wrapper with async step/reset

This commit is contained in:
meelstorm
2026-07-17 17:39:10 +00:00
committed by EugeneTes
parent 2c21dcfdf9
commit 9f34314c2f
2 changed files with 220 additions and 0 deletions

166
Backend/GameProcess.cs Normal file
View 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
{
}