# 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.