From f5ee2049da9a26d312cadf5e2b7c180778c1b2ec Mon Sep 17 00:00:00 2001 From: meelstorm Date: Fri, 17 Jul 2026 16:49:01 +0000 Subject: [PATCH] feat(gamecli): add JSON protocol DTOs with source-gen context --- GameCli.Tests/ProtocolTests.cs | 81 ++++++++++++++++++++++++++++++++++ GameCli/GameCli.csproj | 4 ++ GameCli/Protocol.cs | 56 +++++++++++++++++++++++ 3 files changed, 141 insertions(+) create mode 100644 GameCli.Tests/ProtocolTests.cs create mode 100644 GameCli/Protocol.cs diff --git a/GameCli.Tests/ProtocolTests.cs b/GameCli.Tests/ProtocolTests.cs new file mode 100644 index 0000000..bc47a1e --- /dev/null +++ b/GameCli.Tests/ProtocolTests.cs @@ -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); + } +} diff --git a/GameCli/GameCli.csproj b/GameCli/GameCli.csproj index 206b89a..fb5fa1f 100644 --- a/GameCli/GameCli.csproj +++ b/GameCli/GameCli.csproj @@ -7,4 +7,8 @@ enable + + + + diff --git a/GameCli/Protocol.cs b/GameCli/Protocol.cs new file mode 100644 index 0000000..f3ede99 --- /dev/null +++ b/GameCli/Protocol.cs @@ -0,0 +1,56 @@ +using System.Text.Json.Serialization; + +namespace GameCli; + +/// Input line from the client (per step OR a reset command). +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; } +} + +/// Output line from the CLI after each step or reset. +public sealed class StepOutput +{ + [JsonPropertyName("obs")] public float[] Obs { get; set; } = System.Array.Empty(); + [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; } +} + +/// Renderable ship pose (subset of ShipState that the UI actually draws). +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; } +} + +/// Startup handshake emitted as the very first stdout line. +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; } +} + +/// Source-generated JSON contracts for AOT-friendly, allocation-lean (de)serialization. +[JsonSourceGenerationOptions(WriteIndented = false)] +[JsonSerializable(typeof(StepInput))] +[JsonSerializable(typeof(StepOutput))] +[JsonSerializable(typeof(ShipStateDto))] +[JsonSerializable(typeof(InitMessage))] +[JsonSerializable(typeof(InitPayload))] +internal partial class ProtocolJsonContext : JsonSerializerContext +{ +}