Turn bound has one owner, the actuators; pin the turn rate and the 90 degree boundary

Ship.Update no longer clamps Turn: Thruster.Axis saturates and AetherDrive.Axis
clamps, which is where the bound is enforced. TurnIsClampedToOne becomes
TheActuatorsBoundATurnBeyondOne and asserts the actuator bounds directly. The turn
rate is pinned against a value computed from each thruster's Torque, Thrust,
TorqueMultiplier and the hull's Mass; the behind-the-hull rule is probed at 85 and
95 degrees.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-07 19:11:34 +02:00
commit b644fceb24
2 changed files with 60 additions and 7 deletions

View file

@ -278,12 +278,11 @@ public class Ship : Entity
foreach (var thruster in _forwardThrusters) thruster.Axis += MovementDirection.y;
foreach (var thruster in _reverseThrusters) thruster.Axis += -MovementDirection.y;
var turn = clamp(Turn, -1f, 1f);
foreach (var thruster in _clockwiseThrusters) thruster.Axis += turn;
foreach (var thruster in _counterClockwiseThrusters) thruster.Axis += -turn;
foreach (var thruster in _clockwiseThrusters) thruster.Axis += Turn;
foreach (var thruster in _counterClockwiseThrusters) thruster.Axis += -Turn;
foreach (var drive in _aetherDrives)
drive.Axis = float3(MovementDirection.y, MovementDirection.x, turn);
drive.Axis = float3(MovementDirection.y, MovementDirection.x, Turn);
}
var velocityMagnitude = length(Velocity);

View file

@ -228,6 +228,11 @@ public sealed class SteeringTests
using var cache = RestoredHullsTests.OpenCatalog();
var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull);
// Either side of the 90 degree boundary: sqrt shaping at 85, full demand at 95.
Assert.Equal(sqrt(sin(radians(85f))), Steering.Toward(ship, Heading(ship, 85f)), 5);
Assert.Equal(-sqrt(sin(radians(85f))), Steering.Toward(ship, Heading(ship, -85f)), 5);
Assert.Equal(1f, Steering.Toward(ship, Heading(ship, 95f)));
Assert.Equal(-1f, Steering.Toward(ship, Heading(ship, -95f)));
Assert.Equal(1f, Steering.Toward(ship, Heading(ship, 100f)));
Assert.Equal(-1f, Steering.Toward(ship, Heading(ship, -100f)));
Assert.Equal(1f, Steering.Toward(ship, Heading(ship, 135f)));
@ -235,10 +240,10 @@ public sealed class SteeringTests
Assert.Equal(-sqrt(sin(radians(80f))), Steering.Toward(ship, Heading(ship, -80f)), 4);
}
// A Turn of 3 rotates the hull exactly as a Turn of 1 does. Ship.Update clamps it, and every consumer (Thruster.Axis,
// AetherDrive.Axis) clamps again, so removing the Ship clamp alone changes nothing observable.
// The turn bound has one owner, the actuators: Ship.Update hands Turn to them unclamped, Thruster.Axis saturates to
// 0..1 and AetherDrive.Axis clamps to -1..1. A Turn of 3 therefore rotates the hull as a Turn of 1.
[Fact]
public void TurnIsClampedToOne()
public void TheActuatorsBoundATurnBeyondOne()
{
using var cache = RestoredHullsTests.OpenCatalog();
var one = RestoredHullsTests.BuildThrustedShip(cache, Hull);
@ -262,6 +267,55 @@ public sealed class SteeringTests
Assert.True(Angle(start, one.Direction) > radians(.02f), "the reference turn is real");
Assert.True(Angle(one.Direction, three.Direction) < radians(.001f), $"{Angle(one.Direction, three.Direction)}");
Assert.True(Angle(minusOne.Direction, minusThree.Direction) < radians(.001f));
// The bound itself, at its owners.
foreach (var thruster in one.Equipment.SelectMany(i => i.Behaviors).OfType<Thruster>())
{
thruster.Axis = 3f;
Assert.Equal(1f, thruster.Axis);
thruster.Axis = -3f;
Assert.Equal(0f, thruster.Axis);
}
// Axis is a pure property; no catalog hull carries a drive, so the instance is made without its item.
var drive = (AetherDrive)System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject(typeof(AetherDrive));
drive.Axis = float3(3f, -3f, 3f);
Assert.Equal(float3(1f, -1f, 1f), drive.Axis);
drive.Axis = float3(0f, 0f, -3f);
Assert.Equal(-1f, drive.Axis.z);
}
// The size of a turn, computed from the hull and not read back from the code under test: each rotation thruster
// at Axis 1 turns the hull by Torque * Thrust * TorqueMultiplier / Mass * dt per tick (Thruster.Execute's law,
// summed here from the thrusters' own stats over the sets whose Torque clears the floor).
[Theory]
[InlineData(1f)]
[InlineData(-1f)]
[InlineData(.5f)]
[InlineData(-.5f)]
public void ATurnRotatesAtTheRateTheHullsTorqueGives(float turn)
{
using var cache = RestoredHullsTests.OpenCatalog();
var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull);
var settings = RestoredHullsTests.Settings();
const float dt = 1f / 60f;
var thrusters = ship.Equipment.SelectMany(i => i.Behaviors).OfType<Thruster>().ToArray();
var nose = normalize(ship.Direction);
var right = Right(ship);
ship.Turn = turn;
ship.Update(dt);
float Rate(Func<Thruster, bool> set, float axis) =>
thrusters.Where(set).Sum(t => axis * t.Torque * t.Thrust * settings.TorqueMultiplier / ship.Mass * dt);
var clockwise = Rate(t => t.Torque > settings.TorqueFloor, max(turn, 0f));
var counter = Rate(t => t.Torque < -settings.TorqueFloor, max(-turn, 0f));
var expected = clockwise + counter;
var after = normalize(ship.Direction);
var turned = atan2(dot(after, right), dot(after, nose)); // positive is clockwise
Assert.True(abs(expected) > radians(.01f), $"the expected turn is real: {expected}");
Assert.InRange(abs(turned / expected), .98f, 1.02f);
}
// A state that writes a constant turn, standing for any piloting state.