diff --git a/docs/superpowers/plans/2026-07-17-01-game-cli.md b/docs/superpowers/plans/2026-07-17-01-game-cli.md new file mode 100644 index 0000000..ef95c12 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-01-game-cli.md @@ -0,0 +1,1090 @@ +# 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.