Files
Experiment_ReinforcementLea…/docs/superpowers/plans/2026-07-17-01-game-cli.md
2026-07-17 18:23:16 +00:00

1091 lines
32 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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;
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>
/// Pure physics stepper. All constants are public so training/tuning can inspect them.
/// Explicit Euler integration at fixed dt.
/// </summary>
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;
/// <summary>
/// Advance the ship state by one dt given a discrete action.
/// Gravity is always applied. Thrusters add to acceleration/torque.
/// </summary>
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;
/// <summary>
/// Reward = -distance_to_target
/// - λ_v · speed
/// - λ_θ · |angle|
/// - λ_fuel · engine_on
/// Coefficients are public so training scripts can log/inspect them.
/// </summary>
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;
/// <summary>
/// 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 ±π.
/// </summary>
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;
/// <summary>Input line from the client (per step OR a reset command).</summary>
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; }
}
/// <summary>Output line from the CLI after each step or reset.</summary>
public sealed class StepOutput
{
[JsonPropertyName("obs")] public float[] Obs { get; set; } = System.Array.Empty<float>();
[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; }
}
/// <summary>Renderable ship pose (subset of ShipState that the UI actually draws).</summary>
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; }
}
/// <summary>Startup handshake emitted as the very first stdout line.</summary>
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; }
}
/// <summary>Source-generated JSON contracts for AOT-friendly, allocation-lean (de)serialization.</summary>
[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 `</Project>`), so the test project can see `internal` types:
```xml
<ItemGroup>
<InternalsVisibleTo Include="GameCli.Tests" />
</ItemGroup>
```
- [ ] **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/<Config>/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.