1091 lines
32 KiB
Markdown
1091 lines
32 KiB
Markdown
# 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.
|