# Game CLI 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 standalone `GameCli` .NET console application that owns lunar-lander physics and speaks the line-delimited JSON stdio protocol from the design spec (`docs/superpowers/specs/2026-07-17-cursor-following-lander-design.md`). **Architecture:** Pure C# console app. `Program.cs` runs a read-line → step → write-line loop. All physics, reward, and observation code is in small pure functions that operate on immutable structs, so unit tests exercise them directly. JSON on the wire uses `System.Text.Json` with source-generated serializer contexts for AOT-friendliness (also fastest). **Tech Stack:** .NET 8 SDK, C# 12, `System.Text.Json`, xUnit for tests. --- ## File structure ``` Experiment_ReinforcementLearning/ ├── GameCli.sln ├── GameCli/ │ ├── GameCli.csproj │ ├── Program.cs # main read-line loop + startup handshake │ ├── Ship.cs # ShipState struct │ ├── Physics.cs # constants + pure Step() function │ ├── Reward.cs # pure Compute() function │ ├── Observation.cs # pure Build() function │ └── Protocol.cs # DTOs (StepInput, StepOutput, InitMessage, ResetCommand) + JsonSerializerContext └── GameCli.Tests/ ├── GameCli.Tests.csproj ├── PhysicsTests.cs ├── RewardTests.cs ├── ObservationTests.cs ├── ProtocolTests.cs └── ProgramSmokeTests.cs # end-to-end: launch the built exe, pipe stdin, assert stdout ``` Each file has one responsibility. `Program.cs` is the only file that touches stdin/stdout — everything else is pure and directly unit-testable. --- ## Task 1: Scaffold the .NET solution **Files:** - Create: `GameCli.sln` - Create: `GameCli/GameCli.csproj` - Create: `GameCli.Tests/GameCli.Tests.csproj` - [ ] **Step 1: Verify .NET 8 SDK is installed** Run: `dotnet --list-sdks` Expected: at least one `8.0.x` line. If none, install .NET 8 SDK from https://dot.net. - [ ] **Step 2: Create the solution and both projects** Run from the repo root: ```bash dotnet new sln -n GameCli dotnet new console -n GameCli -o GameCli -f net8.0 dotnet new xunit -n GameCli.Tests -o GameCli.Tests -f net8.0 dotnet sln add GameCli/GameCli.csproj GameCli.Tests/GameCli.Tests.csproj dotnet add GameCli.Tests/GameCli.Tests.csproj reference GameCli/GameCli.csproj ``` - [ ] **Step 3: Delete the boilerplate `Program.cs` bodies** Overwrite `GameCli/Program.cs` with a stub so Task 8 can rewrite it cleanly: ```csharp // Placeholder. Rewritten in Task 8. System.Console.Error.WriteLine("GameCli placeholder"); ``` Delete `GameCli.Tests/UnitTest1.cs` if the xunit template created it: ```bash rm -f GameCli.Tests/UnitTest1.cs ``` - [ ] **Step 4: Verify it builds** Run: `dotnet build` Expected: `Build succeeded. 0 Warning(s). 0 Error(s).` - [ ] **Step 5: Verify the empty test project runs** Run: `dotnet test` Expected: `Passed! - Failed: 0, Passed: 0, Skipped: 0` (0 tests, all pass). - [ ] **Step 6: Commit** ```bash git add GameCli.sln GameCli/ GameCli.Tests/ git commit -m "feat(gamecli): scaffold .NET solution and test project" ``` --- ## Task 2: `ShipState` struct **Files:** - Create: `GameCli/Ship.cs` - Test: `GameCli.Tests/ObservationTests.cs` (indirectly — `ShipState` is used pervasively, no dedicated test file) - [ ] **Step 1: Write `Ship.cs`** ```csharp namespace GameCli; /// /// 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. /// 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); } ``` - [ ] **Step 2: Verify it builds** Run: `dotnet build` Expected: build succeeds. - [ ] **Step 3: Commit** ```bash git add GameCli/Ship.cs git commit -m "feat(gamecli): add ShipState record struct" ``` --- ## Task 3: `Physics` — constants and pure Step function **Files:** - Create: `GameCli/Physics.cs` - Create: `GameCli.Tests/PhysicsTests.cs` - [ ] **Step 1: Write the failing tests** Create `GameCli.Tests/PhysicsTests.cs`: ```csharp 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); } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `dotnet test` Expected: build error — `Physics` does not exist. - [ ] **Step 3: Write `Physics.cs`** ```csharp namespace GameCli; /// /// Pure physics stepper. All constants are public so training/tuning can inspect them. /// Explicit Euler integration at fixed dt. /// 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; /// /// Advance the ship state by one dt given a discrete action. /// Gravity is always applied. Thrusters add to acceleration/torque. /// 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); } } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `dotnet test --filter FullyQualifiedName~PhysicsTests` Expected: 6 passed, 0 failed. - [ ] **Step 5: Commit** ```bash git add GameCli/Physics.cs GameCli.Tests/PhysicsTests.cs git commit -m "feat(gamecli): implement pure Physics.Step with unit tests" ``` --- ## Task 4: `Reward` — pure reward computation **Files:** - Create: `GameCli/Reward.cs` - Create: `GameCli.Tests/RewardTests.cs` - [ ] **Step 1: Write the failing tests** Create `GameCli.Tests/RewardTests.cs`: ```csharp 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"); } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `dotnet test` Expected: build error — `Reward` does not exist. - [ ] **Step 3: Write `Reward.cs`** ```csharp namespace GameCli; /// /// Reward = -distance_to_target /// - λ_v · speed /// - λ_θ · |angle| /// - λ_fuel · engine_on /// Coefficients are public so training scripts can log/inspect them. /// 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 engineOn = action == Physics.ActionNoop ? 0f : 1f; return -distance - LambdaVelocity * speed - LambdaAngle * MathF.Abs(s.Angle) - LambdaFuel * engineOn; } } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `dotnet test --filter FullyQualifiedName~RewardTests` Expected: 5 passed, 0 failed. - [ ] **Step 5: Commit** ```bash git add GameCli/Reward.cs GameCli.Tests/RewardTests.cs git commit -m "feat(gamecli): implement pure Reward.Compute with unit tests" ``` --- ## Task 5: `Observation` — pure observation vector builder **Files:** - Create: `GameCli/Observation.cs` - Create: `GameCli.Tests/ObservationTests.cs` - [ ] **Step 1: Write the failing tests** Create `GameCli.Tests/ObservationTests.cs`: ```csharp 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); } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `dotnet test` Expected: build error — `Observation` does not exist. - [ ] **Step 3: Write `Observation.cs`** ```csharp namespace GameCli; /// /// 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 ±π. /// 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, }; } } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `dotnet test --filter FullyQualifiedName~ObservationTests` Expected: 5 passed, 0 failed. - [ ] **Step 5: Commit** ```bash git add GameCli/Observation.cs GameCli.Tests/ObservationTests.cs git commit -m "feat(gamecli): implement Observation.Build with unit tests" ``` --- ## Task 6: `Protocol` — JSON DTOs and serializer context **Files:** - Create: `GameCli/Protocol.cs` - Create: `GameCli.Tests/ProtocolTests.cs` - [ ] **Step 1: Write the failing tests** Create `GameCli.Tests/ProtocolTests.cs`: ```csharp 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); } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `dotnet test` Expected: build error — `StepInput`, `StepOutput`, etc. do not exist. - [ ] **Step 3: Write `Protocol.cs`** ```csharp 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 { } ``` - [ ] **Step 4: Make `ProtocolJsonContext` visible to tests** Add to `GameCli/GameCli.csproj` (before the closing ``), so the test project can see `internal` types: ```xml ``` - [ ] **Step 5: Run tests to verify they pass** Run: `dotnet test --filter FullyQualifiedName~ProtocolTests` Expected: 5 passed, 0 failed. - [ ] **Step 6: Commit** ```bash git add GameCli/Protocol.cs GameCli/GameCli.csproj GameCli.Tests/ProtocolTests.cs git commit -m "feat(gamecli): add JSON protocol DTOs with source-gen context" ``` --- ## Task 7: `Program.cs` — main loop wiring stdio to physics **Files:** - Modify: `GameCli/Program.cs` (overwrite the Task 1 stub) - [ ] **Step 1: Overwrite `Program.cs`** ```csharp 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); } ``` - [ ] **Step 2: Verify it builds** Run: `dotnet build` Expected: build succeeds. Warnings are OK; errors are not. - [ ] **Step 3: Smoke-run the binary by hand** Run: ```bash echo '{"action":0,"target":[0.5,0.5]}' | dotnet run --project GameCli -c Release ``` Expected: two JSON lines on stdout — first the `init` handshake, second a step output. - [ ] **Step 4: Commit** ```bash git add GameCli/Program.cs git commit -m "feat(gamecli): wire stdio read-line loop to physics + reward + observation" ``` --- ## Task 8: End-to-end smoke test — spawn the built binary **Files:** - Create: `GameCli.Tests/ProgramSmokeTests.cs` - [ ] **Step 1: Write the failing test** Create `GameCli.Tests/ProgramSmokeTests.cs`: ```csharp using System.Diagnostics; using System.Text.Json; using GameCli; using Xunit; namespace GameCli.Tests; public class ProgramSmokeTests { private static Process StartGameCli() { // Locate the GameCli binary relative to the test binary's output dir. // Tests run from GameCli.Tests/bin//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}"); 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"); 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"); 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"); 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()}"); } } ``` - [ ] **Step 2: Run tests to verify they pass** Run: `dotnet test --filter FullyQualifiedName~ProgramSmokeTests` Expected: 3 passed, 0 failed. If a smoke test hangs waiting on `ReadLine`, the CLI probably didn't flush stdout. Verify `Console.Out.Flush()` calls are in place. - [ ] **Step 3: Run the full test suite** Run: `dotnet test` Expected: 24 total (6 physics + 5 reward + 5 observation + 5 protocol + 3 smoke), all passed. - [ ] **Step 4: Commit** ```bash git add GameCli.Tests/ProgramSmokeTests.cs git commit -m "test(gamecli): end-to-end smoke tests exercising the built binary" ``` --- ## Task 9: Publish a self-contained binary and manual verification **Files:** (no new files) - [ ] **Step 1: Publish a Release build** Run: `dotnet publish GameCli/GameCli.csproj -c Release -o publish/GameCli` Expected: `publish/GameCli/GameCli` (or `GameCli.exe` on Windows) exists. - [ ] **Step 2: Manually drive a few steps** Run: ```bash (echo '{"action":0,"target":[0.5,0.5]}'; \ echo '{"action":2,"target":[0.5,0.3]}'; \ echo '{"action":2,"target":[0.5,0.3]}'; \ echo '{"cmd":"reset","seed":1}'; \ echo '{"action":0,"target":[0.5,0.5]}') \ | ./publish/GameCli/GameCli ``` Expected output shape: 1. Line 1 = `init` message. 2. Lines 2-4 = `step` outputs with `step` counter 1, 2, 3. VY should be positive on line 2 (gravity), and start decreasing on lines 3-4 (main engine fighting gravity). 3. Line 5 = reset output with `step: 0`. 4. Line 6 = first step after reset, `step: 1`. - [ ] **Step 3: Update `.gitignore` to exclude `publish/`** Append to `.gitignore`: ``` publish/ ``` - [ ] **Step 4: Commit** ```bash git add .gitignore git commit -m "chore: gitignore publish/ directory" ``` --- ## Definition of done for Plan 1 - `dotnet test` reports 24 passed, 0 failed. - `dotnet publish GameCli/GameCli.csproj -c Release` produces a runnable binary. - Manually feeding step JSON to the binary produces well-formed observation JSON. - No file exceeds ~150 lines; every source file has a single clear responsibility. - All work committed as small feat/test commits on `main`. The next plan (Plan 2) will wrap this CLI as a `gym.Env` in Python and train the PPO policy.