From c86423936efc3a74739ddc1d054603bc2b031673 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 12:33:20 +0200 Subject: [PATCH 01/11] Helm split, simulation: Ship.Turn is the only facing command; LookDirection becomes Aim Ship.Update reads Turn and feeds the rotation thrusters and aether drives; it no longer reads the look direction or snaps Direction toward it. Steering.Toward is the one law from heading to demand (a heading astern now turns at full demand). HelmInput owns the face-the-aim state. Accelerate, Combat and MoveTo write Turn through Steering.Toward. Entity.LookDirection is renamed Aim in the simulation, the Unity readers and the tests. Unity player input follows in the next commit. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/Gameplay/EntityInstance.cs | 2 +- Assets/Scripts/Gameplay/ShipInstance.cs | 2 +- .../Weapons/GuidedProjectileManager.cs | 2 +- Assets/Scripts/ServerShared/Agents/Agent.cs | 4 +- .../ServerShared/Agents/States/Combat.cs | 3 +- .../ServerShared/Agents/States/MoveTo.cs | 3 +- .../ServerShared/Behaviors/LockWeapon.cs | 2 +- .../Behaviors/TurretController.cs | 4 +- Assets/Scripts/ServerShared/Entity.cs | 6 +- Assets/Scripts/ServerShared/FireControl.cs | 6 +- Assets/Scripts/ServerShared/HelmInput.cs | 18 ++ Assets/Scripts/ServerShared/Ship.cs | 19 +- Assets/Scripts/ServerShared/Steering.cs | 24 +++ Assets/Scripts/Zone Display/ZoneRenderer.cs | 2 +- tests/Aetheria.Shared.Tests/BrownoutTests.cs | 10 +- .../ConditionRatioTests.cs | 2 +- .../FireControlCut124Tests.cs | 10 +- .../FireControlCut8Tests.cs | 2 +- .../FireControlCut9Tests.cs | 2 +- .../FireControlPerWeaponTests.cs | 2 +- .../Aetheria.Shared.Tests/FireControlTests.cs | 43 ++++- .../IffAndCombatTests.cs | 2 +- .../Aetheria.Shared.Tests/MiningCut3Tests.cs | 6 +- .../Aetheria.Shared.Tests/MiningIndexTests.cs | 2 +- .../MiningTargetQueriesTests.cs | 14 +- .../RestoredHullsTests.cs | 36 ++-- tests/Aetheria.Shared.Tests/ScenarioTests.cs | 4 +- tests/Aetheria.Shared.Tests/SteeringTests.cs | 177 ++++++++++++++++++ .../TargetingSystemTests.cs | 2 +- 29 files changed, 331 insertions(+), 80 deletions(-) create mode 100644 Assets/Scripts/ServerShared/HelmInput.cs create mode 100644 Assets/Scripts/ServerShared/Steering.cs create mode 100644 tests/Aetheria.Shared.Tests/SteeringTests.cs diff --git a/Assets/Scripts/Gameplay/EntityInstance.cs b/Assets/Scripts/Gameplay/EntityInstance.cs index 31b6d093c..fb356dd75 100644 --- a/Assets/Scripts/Gameplay/EntityInstance.cs +++ b/Assets/Scripts/Gameplay/EntityInstance.cs @@ -398,7 +398,7 @@ public class EntityInstance : MonoBehaviour x.Value.material.SetFloat("_Emission", Entity.ItemManager.GameplaySettings.TemperatureEmissionCurve.Evaluate(x.Key.RadiatorTemperature)); } - LookAtPoint.position = transform.position + Entity.LookDirection.ToUnity() * + LookAtPoint.position = transform.position + Entity.Aim.ToUnity() * (!Entity.Target.Value.IsNone ? max(Entity.TargetRange,Entity.ItemManager.GameplaySettings.ConvergenceMinimumDistance) : 10000); LocalSpace.localPosition = transform.position = Entity.Position.ToUnity(); if (_influenceInstance) diff --git a/Assets/Scripts/Gameplay/ShipInstance.cs b/Assets/Scripts/Gameplay/ShipInstance.cs index 8f4fe40bf..cc110fc8f 100644 --- a/Assets/Scripts/Gameplay/ShipInstance.cs +++ b/Assets/Scripts/Gameplay/ShipInstance.cs @@ -113,7 +113,7 @@ public class ShipInstance : EntityInstance base.Update(); TractorBeam.Power = Entity.TractorPower; - TractorBeam.Direction = Entity.LookDirection.ToUnity(); + TractorBeam.Direction = Entity.Aim.ToUnity(); if (_aetherDrive != null) { diff --git a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs index a3302a93e..876dda1f6 100644 --- a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs @@ -76,7 +76,7 @@ public class GuidedProjectileManager : InstantWeaponEffectManager p.Velocity = barrel.forward * weapon.Velocity; p.Thrust = item.Evaluate(guidance.Thrust); p.TopSpeed = item.Evaluate(guidance.MissileVelocity); - p.TargetPosition = () => (source.Entity.Position + length(source.LookAtPoint.position.ToCultMath() - source.Entity.Position) * source.Entity.LookDirection).ToUnity(); + p.TargetPosition = () => (source.Entity.Position + length(source.LookAtPoint.position.ToCultMath() - source.Entity.Position) * source.Entity.Aim).ToUnity(); Bind(p, source.Entity.Zone, shotId); } else Debug.LogError($"Weapon {item.Data.Name} linked to {name} effect, but is not a Launcher!"); diff --git a/Assets/Scripts/ServerShared/Agents/Agent.cs b/Assets/Scripts/ServerShared/Agents/Agent.cs index e9710b464..403aece7d 100644 --- a/Assets/Scripts/ServerShared/Agents/Agent.cs +++ b/Assets/Scripts/ServerShared/Agents/Agent.cs @@ -63,10 +63,10 @@ public class Agent // If Delta V is above the threshold, direct the ship towards the delta and use only main thrusters if (!noTurn && deltaVMag > FORWARD_DELTA_THRESHOLD) { - Ship.LookDirection = float3(deltaVDirection.x, 0, deltaVDirection.y); + Ship.Turn = Steering.Toward(Ship, deltaVDirection); Ship.MovementDirection = float2(0, pow(dot(Ship.Direction, deltaVDirection), 2)); } - // If Delta V is low, direct the ship towards the target and use all thrusters + // If Delta V is low, use all thrusters and leave Turn to the caller (MoveTo already pointed it at the target) else if(deltaVMag > THRUST_DELTA_THRESHOLD) { var right = Ship.Direction.Rotate(ItemRotation.Clockwise); diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index c93fb3c99..092c6af4c 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -124,7 +124,8 @@ public class CombatState : BaseState weapon.Deactivate(); } } - _agent.Ship.LookDirection = toTarget; + _agent.Ship.Aim = toTarget; + _agent.Ship.Turn = Steering.Toward(_agent.Ship, toTarget.xz); _agent.Accelerate(movementDirection * _agent.TopSpeed, true);//selectedGroup >= 0); // Fire charged guns! diff --git a/Assets/Scripts/ServerShared/Agents/States/MoveTo.cs b/Assets/Scripts/ServerShared/Agents/States/MoveTo.cs index 39c4e1135..b677cf0cd 100644 --- a/Assets/Scripts/ServerShared/Agents/States/MoveTo.cs +++ b/Assets/Scripts/ServerShared/Agents/States/MoveTo.cs @@ -23,7 +23,8 @@ public abstract class MoveToState : BaseState // We want to go top speed in the direction of our target var desiredVelocity = dir * _agent.TopSpeed; - _agent.Ship.LookDirection = float3(dir.x, 0, dir.y); + _agent.Ship.Aim = float3(dir.x, 0, dir.y); + _agent.Ship.Turn = Steering.Toward(_agent.Ship, dir); _agent.Accelerate(desiredVelocity); } } diff --git a/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs index 6b88f4ba4..16f56b72d 100644 --- a/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs @@ -97,7 +97,7 @@ public class LockWeapon : InstantWeapon DirectionImpact = Evaluate(_data.DirectionImpact); Decay = Evaluate(_data.Decay); - var degrees = acos(dot(normalize(target.Position - Entity.Position), normalize(Entity.LookDirection))) * 57.2958f; + var degrees = acos(dot(normalize(target.Position - Entity.Position), normalize(Entity.Aim))) * 57.2958f; if (degrees < LockAngle) { var lerp = 1 - unlerp(0, 90, degrees); diff --git a/Assets/Scripts/ServerShared/Behaviors/TurretController.cs b/Assets/Scripts/ServerShared/Behaviors/TurretController.cs index a5a395373..654ee5ec1 100644 --- a/Assets/Scripts/ServerShared/Behaviors/TurretController.cs +++ b/Assets/Scripts/ServerShared/Behaviors/TurretController.cs @@ -71,10 +71,10 @@ public class TurretController : Behavior, IInitializableBehavior target.Position, targetVelocity ); predictedPosition.y = Entity.Zone.GetHeight(predictedPosition.xz) + targetHullData.GridOffset; - Entity.LookDirection = normalize(predictedPosition - Entity.Position); + Entity.Aim = normalize(predictedPosition - Entity.Position); } else - Entity.LookDirection = normalize(diff); + Entity.Aim = normalize(diff); foreach (var x in _weapons) { diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 853bea91e..bcd6ad7ab 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -55,7 +55,9 @@ public abstract class Entity // notice and clear it. public ReactiveProperty TargetItem = new ReactiveProperty((EquippedItem)null); - public float3 LookDirection; + // Where the pilot points: reticle, lock cone, tractor, guided rounds, fire direction. Steers nothing; the + // hull turns only by Ship.Turn. + public float3 Aim; public string Name; @@ -406,7 +408,7 @@ public abstract class Entity // The candidate nearest the look direction by planar angle; picking the held target again clears it. public bool TargetUnderReticle() { - var look = float2(LookDirection.x, LookDirection.z); + var look = float2(Aim.x, Aim.z); if (!PickTarget(new AngleKey(this, Position.xz, look), out var picked)) return false; return SetTarget(Target.Value.Equals(picked) ? TargetRef.None : picked); } diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 04e960d33..ccab1b14e 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -107,16 +107,16 @@ public static class FireControl } // Operator ruling 2026-09-30: a shot with nothing to bear on flies along where its shooter is aiming - // (Entity.LookDirection -- the player's mouse or a turret's tracking), restricted by the mount's arc. The + // (Entity.Aim -- the player's mouse or a turret's tracking), restricted by the mount's arc. The // arc is InArc's own cone: an aim inside it is used as is, one outside it is turned to the nearest edge of // the same cone. There is no second bearing test here; the edge is where InArc stops passing. A shooter - // with no aim set (LookDirection zero) aims down the mount, the same fallback InArc takes for a zero bearing. + // with no aim set (Aim zero) aims down the mount, the same fallback InArc takes for a zero bearing. // The simulation had no aim clamp before this: the Unity barrel's ArticulationPoint clamps the picture, // and a target outside the arc merely priced the shot at zero. public static float2 AimDirection(EquippedItem item, Entity shooter) { var mount = MountDirection(item).xz; - var look = shooter.LookDirection.xz; + var look = shooter.Aim.xz; if (lengthsq(look) < 1e-6f) return mount; look = normalize(look); if (InArc(item, float3(look.x, 0, look.y))) return look; diff --git a/Assets/Scripts/ServerShared/HelmInput.cs b/Assets/Scripts/ServerShared/HelmInput.cs new file mode 100644 index 000000000..fe1316aff --- /dev/null +++ b/Assets/Scripts/ServerShared/HelmInput.cs @@ -0,0 +1,18 @@ +using CultMath; +using static CultMath.math; + +// The player's face-the-aim state, engine-free. Held follows the Face Aim hold input each frame; Latched is +// flipped by the Caps Lock press. Either one faces the aim; neither leaves the hull to the turn axis. +// Runtime only, never saved: Latched starts false each session. +public class HelmInput +{ + public bool Held; + public bool Latched; + + public bool FaceAim => Held || Latched; + + public void Toggle() => Latched = !Latched; + + public float Demand(Ship ship, float2 aim, float turnAxis) => + FaceAim ? Steering.Toward(ship, aim) : clamp(turnAxis, -1f, 1f); +} diff --git a/Assets/Scripts/ServerShared/Ship.cs b/Assets/Scripts/ServerShared/Ship.cs index d10f097ca..875dd81be 100644 --- a/Assets/Scripts/ServerShared/Ship.cs +++ b/Assets/Scripts/ServerShared/Ship.cs @@ -25,6 +25,9 @@ public class Ship : Entity // [IgnoreMember] public Targetable Target; public Entity HomeEntity; public float2 MovementDirection; + // The one facing command, -1..1, positive clockwise. Written each tick by the pilot (player input or the + // ship's agent); a heading becomes a demand only through Steering.Toward. Runtime only, never packed. + public float Turn; public bool IsPlayerShip; private HashSet _thrusterItems; @@ -275,20 +278,12 @@ public class Ship : Entity foreach (var thruster in _forwardThrusters) thruster.Axis += MovementDirection.y; foreach (var thruster in _reverseThrusters) thruster.Axis += -MovementDirection.y; - var look = normalize(LookDirection.xz); - var deltaRot = dot(look, normalize(Direction).Rotate(ItemRotation.Clockwise)); - if (abs(deltaRot) < .01f) - { - deltaRot = 0; - Direction = lerp(Direction, look, min(delta, 1)); - } - deltaRot = pow(abs(deltaRot), .5f) * sign(deltaRot); - - foreach (var thruster in _clockwiseThrusters) thruster.Axis += deltaRot; - foreach (var thruster in _counterClockwiseThrusters) thruster.Axis += -deltaRot; + var turn = clamp(Turn, -1f, 1f); + 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, deltaRot); + drive.Axis = float3(MovementDirection.y, MovementDirection.x, turn); } var velocityMagnitude = length(Velocity); diff --git a/Assets/Scripts/ServerShared/Steering.cs b/Assets/Scripts/ServerShared/Steering.cs new file mode 100644 index 000000000..de477332f --- /dev/null +++ b/Assets/Scripts/ServerShared/Steering.cs @@ -0,0 +1,24 @@ +using System; +using CultMath; +using static CultMath.math; + +// The one law that turns a wanted heading into a facing command. Ship.Update reads only Ship.Turn; whoever +// wants the hull to face somewhere (the player's face-the-aim state, an agent state) asks here and writes Turn. +public static class Steering +{ + const float Deadband = .01f; + + // Positive is clockwise. Lateral component of the heading with sqrt shaping and a deadband; a heading + // behind the hull turns at full demand (its lateral component alone would vanish at exactly astern). + // A zero or non-finite heading holds course. + public static float Toward(Ship ship, float2 heading) + { + if (!float.IsFinite(heading.x) || !float.IsFinite(heading.y) || lengthsq(heading) < 1e-12f) return 0f; + var dir = normalize(ship.Direction); + var wanted = normalize(heading); + var d = dot(wanted, dir.Rotate(ItemRotation.Clockwise)); + if (dot(wanted, dir) < 0f) return d < 0f ? -1f : 1f; + if (abs(d) < Deadband) return 0f; + return sqrt(abs(d)) * sign(d); + } +} diff --git a/Assets/Scripts/Zone Display/ZoneRenderer.cs b/Assets/Scripts/Zone Display/ZoneRenderer.cs index b6021027a..5ef289cde 100644 --- a/Assets/Scripts/Zone Display/ZoneRenderer.cs +++ b/Assets/Scripts/Zone Display/ZoneRenderer.cs @@ -437,7 +437,7 @@ public class ZoneRenderer : MonoBehaviour foreach (var loot in _loot) { loot.ViewOrigin = PerspectiveEntity.Position; - loot.ViewDirection = PerspectiveEntity.LookDirection; + loot.ViewDirection = PerspectiveEntity.Aim; } // if (SlimeRenderer.SpawnPositions.Length != _suns.Length) diff --git a/tests/Aetheria.Shared.Tests/BrownoutTests.cs b/tests/Aetheria.Shared.Tests/BrownoutTests.cs index e9a2dbf11..660b4106b 100644 --- a/tests/Aetheria.Shared.Tests/BrownoutTests.cs +++ b/tests/Aetheria.Shared.Tests/BrownoutTests.cs @@ -121,11 +121,9 @@ public sealed class BrownoutTests : IDisposable Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Consumer")))); zone.Entities.Add(ship); - // A ship already "looking" the way Direction already points keeps Ship.Update's own steering pass a - // no-op (deltaRot == 0): no NaN from an unset LookDirection propagating into AetherDrive's Axis.z, and - // no incidental torque thrust from the ship trying to turn toward a look direction none of these tests - // care about. - ship.LookDirection = float3(0, 0, 1); + // Ship.Turn stays 0, so Ship.Update commands no rotation: no incidental torque thrust from the + // ship trying to turn. Aim steers nothing, so this one is only a plausible aim. + ship.Aim = float3(0, 0, 1); ship.Activate(); return ship; } @@ -184,7 +182,7 @@ public sealed class BrownoutTests : IDisposable // --- AetherDrive: PassiveCoupling = 1 engages the rotor regardless of axis input, so the drive can be // --- exercised without threading Ship's own movement/torque classification -- Axis stays at Ship.Update's - // --- own default (0,0,deltaRot), and deltaRot is 0 by BuildShip's own LookDirection setup. Rpm starts at 0, + // --- own default (0,0,Turn), and Turn is 0 by default. Rpm starts at 0, // --- so decay(0, ...) contributes nothing and the whole tick's Rpm gain is exactly this behaviour's own // --- spin-up under test. --- private (float rpmGain, AetherDrive drive) RunAetherDrive(float reactorCharge, float exponent = 1) diff --git a/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs b/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs index 2ff03d18f..9781f0ec9 100644 --- a/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs +++ b/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs @@ -95,7 +95,7 @@ public sealed class ConditionRatioTests : IDisposable Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Consumer")))); zone.Entities.Add(ship); - ship.LookDirection = float3(0, 0, 1); // Ship.Update's own steering pass stays a no-op, as BrownoutTests + ship.Aim = float3(0, 0, 1); // Turn stays 0, as in BrownoutTests ship.Activate(); consumer = ship.Equipment.Single(e => e.Data.Name == "Consumer"); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index 92b26bbce..c6b1bb00c 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -1155,7 +1155,7 @@ public sealed class FireControlCut124Tests : IDisposable } // Operator ruling 2026-09-30: "fused weapons without a lock explode at max range." A fused shot with no - // target bursts at the weapon's Range along its aim (Entity.LookDirection), whatever its fuse. The fixture + // target bursts at the weapon's Range along its aim (Entity.Aim), whatever its fuse. The fixture // aims the shooter along (2,1), a non-axis facing; witnesses sit at the burst point and beside it. Radius 4 // with the witness hull 5x4 cells of 2 world units: a witness centred on the burst point is covered, and // one 30 units off the aim line is not. @@ -1234,7 +1234,7 @@ public sealed class FireControlCut124Tests : IDisposable // ==== Operator rulings 2026-09-30 (Soul's pass on the no-lock fix): aim, invalid target, contact, shooter ==== - private static void Aim(Engagement e, float2 direction) => e.Shooter.LookDirection = float3(direction.x, 0, direction.y); + private static void Aim(Engagement e, float2 direction) => e.Shooter.Aim = float3(direction.x, 0, direction.y); private static float3 PointAlong(Engagement e, float2 direction, float distance) => e.Shooter.Position + float3(direction.x, 0, direction.y) * distance; @@ -1266,7 +1266,7 @@ public sealed class FireControlCut124Tests : IDisposable } // Ruling 1: an aim inside the arc is used as it is (unnormalised here), and a shooter aiming at nothing - // (LookDirection zero) fires down its mount. Kills: reading MountDirection instead of the aim; a missing + // (Aim zero) fires down its mount. Kills: reading MountDirection instead of the aim; a missing // zero-aim fallback, which would normalise to NaN and burst nowhere. [Fact] public void ANoLockShotFliesAlongTheAimAndDownTheMountWhenNothingIsAimed() @@ -2113,9 +2113,9 @@ public sealed class FireControlCut124Tests : IDisposable Near(float2(0, 1), Clamped(e, float2(0, 0))); Near(float2(0, 1), Clamped(e, float2(5e-4f, 0))); Near(Dir(85), Clamped(e, float2(2e-3f, 0))); - e.Shooter.LookDirection = float3(0, 1, 0); + e.Shooter.Aim = float3(0, 1, 0); Near(float2(0, 1), FireControl.AimDirection(e.WeaponItem, e.Shooter)); - e.Shooter.LookDirection = float3(float.NaN, 0, float.NaN); + e.Shooter.Aim = float3(float.NaN, 0, float.NaN); var nan = FireControl.AimDirection(e.WeaponItem, e.Shooter); Assert.False(float.IsNaN(nan.x) || float.IsNaN(nan.y), "a NaN aim must not produce a NaN flight direction"); } diff --git a/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs index a978aed7f..ffde777bc 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs @@ -224,7 +224,7 @@ public sealed class FireControlCut8Tests : IDisposable locker.Position = float3.zero; bystander.Position = float3(0, 0, 100); - locker.LookDirection = float3(0, 0, 1); + locker.Aim = float3(0, 0, 1); locker.SetTarget(bystander); locker.EntityInfoGathered[bystander] = 1f; locker.SetIff(bystander, true); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs index 2ebeb72f1..8e715d7eb 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs @@ -560,7 +560,7 @@ public sealed class FireControlCut9Tests : IDisposable locker.Position = float3.zero; bystander.Position = float3(0, 0, 100); - locker.LookDirection = float3(0, 0, 1); + locker.Aim = float3(0, 0, 1); locker.SetTarget(bystander); locker.EntityInfoGathered[bystander] = 1f; locker.SetIff(bystander, true); diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index edca68dde..60bc55e00 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -257,7 +257,7 @@ public sealed class FireControlPerWeaponTests : IDisposable return rig; } - private static void Aim(Rig r, float2 direction) => r.Shooter.LookDirection = float3(direction.x, 0, direction.y); + private static void Aim(Rig r, float2 direction) => r.Shooter.Aim = float3(direction.x, 0, direction.y); private static readonly (ItemRotation, float)[] SideThenForward = { (ItemRotation.Clockwise, 0f), (ItemRotation.None, 0f) }; private static readonly (ItemRotation, float)[] SideMount = { (ItemRotation.Clockwise, 0f) }; diff --git a/tests/Aetheria.Shared.Tests/FireControlTests.cs b/tests/Aetheria.Shared.Tests/FireControlTests.cs index 33c90eeba..51d03bd9f 100644 --- a/tests/Aetheria.Shared.Tests/FireControlTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlTests.cs @@ -175,13 +175,8 @@ public sealed class FireControlTests : IDisposable Assert.True(FireControl.InArc(defaultArc, AtAngle(5))); } - // The cut's real deliverable: Entity.HardpointTransforms is gone, so a Minion can step through a full - // Zone.Update tick and reach the fire decision without Unity ever having run. Before this cut, - // Combat.cs:100-102 threw KeyNotFoundException on the first combat tick headless, because the AI's aim - // direction was a readback from a renderer that never ran. Mutation: restore the HardpointTransforms - // read in Behavior.Direction / Combat.cs -- this throws again outside Unity. - [Fact] - public void CombatStateStepsHeadless() + // A shooter with one in-range, in-arc gun, a detected target at `targetPosition`, and a Minion driving the shooter. + private (Ship shooter, Ship target, Zone zone) BuildCombatEncounter(float3 targetPosition) { var (items, shooter, weapons) = BuildShipWithHardpoints((ItemRotation.None, 0f)); var weaponData = (InstantWeaponData) weapons[0].Data.Behaviors.Single(b => b is InstantWeaponData); @@ -203,7 +198,7 @@ public sealed class FireControlTests : IDisposable target.Activate(); shooter.Position = float3.zero; - target.Position = float3(0, 0, 10); // dead ahead of the None-rotation hardpoint, well within range and arc + target.Position = targetPosition; shooter.SetTarget(target); // Cut 3: CombatState now gates on FireControl.HitProbability, which is zero for an undetected target @@ -216,6 +211,19 @@ public sealed class FireControlTests : IDisposable // unaided shot still counts as "worth it." items.GameplaySettings.AgentMinHitProbability = 0f; zone.Agents.Add(new Minion(shooter)); + return (shooter, target, zone); + } + + // The cut's real deliverable: Entity.HardpointTransforms is gone, so a Minion can step through a full + // Zone.Update tick and reach the fire decision without Unity ever having run. Before this cut, + // Combat.cs:100-102 threw KeyNotFoundException on the first combat tick headless, because the AI's aim + // direction was a readback from a renderer that never ran. Mutation: restore the HardpointTransforms + // read in Behavior.Direction / Combat.cs -- this throws again outside Unity. + [Fact] + public void CombatStateStepsHeadless() + { + // dead ahead of the None-rotation hardpoint, well within range and arc + var (shooter, _, zone) = BuildCombatEncounter(float3(0, 0, 10)); var ex = Record.Exception(() => { @@ -227,4 +235,23 @@ public sealed class FireControlTests : IDisposable var weapon = shooter.Weapons.Single(); Assert.True(weapon.Firing); // arc + range satisfied: the fight actually decided to fire, not just avoided a crash } + + // Controls cut (helm split): Combat aims the shooter at the intercept (Aim) and commands the hull toward it + // through the one steering law (Turn); it never turns the hull by writing Aim. + [Fact] + public void CombatAimsAtTheInterceptAndTurnsTowardIt() + { + var (shooter, target, zone) = BuildCombatEncounter(float3(10, 0, 10)); // 45 degrees to starboard, in arc + var direction = shooter.Direction; + + zone.Update(1f); + zone.Update(1f); + + var bearing = normalize(target.Position.xz - shooter.Position.xz); + var aim = normalize(shooter.Aim.xz); + Assert.True(dot(aim, bearing) > .999f, "Aim points at the stationary target's intercept"); + Assert.Equal(direction, shooter.Direction); // no thrusters on this hull: only Turn was commanded + Assert.Equal(Steering.Toward(shooter, shooter.Aim.xz), shooter.Turn); + Assert.True(abs(shooter.Turn) > .5f); + } } diff --git a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs index 4c56b5ff6..e77bfad51 100644 --- a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs +++ b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs @@ -307,7 +307,7 @@ public sealed class IffAndCombatTests : IDisposable // detected (EntityInfoGathered=1 saturates the sensor-impact term). This makes the assertion // below actually depend on the gate, instead of a coincidental zero from unset geometry. shooter.Position = float3(0, 0, 0); - shooter.LookDirection = float3(0, 0, 1); + shooter.Aim = float3(0, 0, 1); target.Position = float3(0, 0, 10); shooter.EntityInfoGathered[target] = 1; diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index 1170ade7a..496955b98 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -224,7 +224,7 @@ public sealed partial class MiningCut3Tests : IDisposable } s.Zone.Entities.Add(ship); Hold(ship, at); - ship.LookDirection = float3(0, 0, 1); + ship.Aim = float3(0, 0, 1); ship.Activate(); return ship; } @@ -588,7 +588,7 @@ public sealed partial class MiningCut3Tests : IDisposable var enemy = SpawnShip(s, eyeAt + float2(0, 50)); shooter.SetIff(enemy, true); shooter.EntityInfoGathered[enemy] = 1; - shooter.LookDirection = float3(0, 0, 1); + shooter.Aim = float3(0, 0, 1); var launcher = new LockWeapon(new LockWeaponData { LockSpeed = Constant(.5f), @@ -817,7 +817,7 @@ public sealed partial class MiningCut3Tests : IDisposable foreach (var weapon in weapons) Assert.True(ship.TryEquip(Mint(s, weapon))); s.Zone.Entities.Add(ship); Hold(ship, eyeAt); - ship.LookDirection = float3(0, 0, 1); + ship.Aim = float3(0, 0, 1); ship.Activate(); Tick((ship, eyeAt)); // weapons read their ranges when they run return ship; diff --git a/tests/Aetheria.Shared.Tests/MiningIndexTests.cs b/tests/Aetheria.Shared.Tests/MiningIndexTests.cs index d6880d32d..785854911 100644 --- a/tests/Aetheria.Shared.Tests/MiningIndexTests.cs +++ b/tests/Aetheria.Shared.Tests/MiningIndexTests.cs @@ -56,7 +56,7 @@ public sealed partial class MiningCut3Tests Assert.True(ship.TryEquip(Mint(s, gun))); s.Zone.Entities.Add(ship); Hold(ship, at); - ship.LookDirection = float3(0, 0, 1); + ship.Aim = float3(0, 0, 1); ship.Activate(); Tick((ship, at)); // equipment comes online and weapons read their ranges on the first tick return ship; diff --git a/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs b/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs index 5cfd6d72b..64304e066 100644 --- a/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs +++ b/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs @@ -157,7 +157,7 @@ public sealed partial class MiningCut3Tests for (var i = 0; i < 60; i++) { var heading = rng.NextDouble() * 2 * Math.PI; - observer.LookDirection = float3((float) Math.Cos(heading), 0, (float) Math.Sin(heading)); + observer.Aim = float3((float) Math.Cos(heading), 0, (float) Math.Sin(heading)); double Angle(TargetRef t) { var to = Where(t) - at; @@ -188,7 +188,7 @@ public sealed partial class MiningCut3Tests var s = BuildSceneAt(float2.zero, 20000f, 0, null, new[] { kind }, Belt(kind, Annulus(new System.Random(21), count, 2000f, outer))); var at = 2100f * float2(cos(1f), sin(1f)); var observer = Observer(s, at, 150f, 3f, Falloff()); - observer.LookDirection = float3(1, 0, 0); + observer.Aim = float3(1, 0, 0); var order = ByDistance(s, observer, 150f); Assert.True(order.Count > 5); if (held) observer.SetTarget(order[order.Count / 2]); @@ -262,7 +262,7 @@ public sealed partial class MiningCut3Tests Check("Previous from the median rock", Cost(observer.TargetPrevious, median), previousHeld); foreach (var heading in new[] { 0f, 2f, 4f }) { - observer.LookDirection = float3(cos(heading), 0, sin(heading)); + observer.Aim = float3(cos(heading), 0, sin(heading)); Check($"the reticle at {heading}", Cost(observer.TargetUnderReticle, TargetRef.None), reticle); } s.Zone.Entities.Remove(observer); @@ -314,7 +314,7 @@ public sealed partial class MiningCut3Tests for (var i = 0; i < 200; i++) { var heading = rng.NextDouble() * 2 * Math.PI; - observer.LookDirection = float3((float) Math.Cos(heading), 0, (float) Math.Sin(heading)); + observer.Aim = float3((float) Math.Cos(heading), 0, (float) Math.Sin(heading)); observer.SetTarget(TargetRef.None); switch (i % 3) { @@ -338,7 +338,7 @@ public sealed partial class MiningCut3Tests Assert.True(ship.TryEquip(Mint(s, launcher))); s.Zone.Entities.Add(ship); Hold(ship, at); - ship.LookDirection = float3(0, 0, 1); + ship.Aim = float3(0, 0, 1); ship.Activate(); Tick((ship, at)); Assert.Contains(ship.Weapons, w => w.Range == 400f); @@ -410,7 +410,7 @@ public sealed partial class MiningCut3Tests // visible target, never a hidden one. for (var heading = 0f; heading < 2 * PI; heading += .7f) { - observer.LookDirection = float3(sin(heading), 0, cos(heading)); + observer.Aim = float3(sin(heading), 0, cos(heading)); observer.SetTarget(TargetRef.None); Assert.True(observer.TargetUnderReticle()); Assert.DoesNotContain(observer.Target.Value, hidden); @@ -462,7 +462,7 @@ public sealed partial class MiningCut3Tests } // The reticle, looking at the spot: the ship; then with the ship gone from sight, the first chunk. - observer.LookDirection = float3(0, 0, 1); + observer.Aim = float3(0, 0, 1); observer.SetTarget(TargetRef.None); Assert.True(observer.TargetUnderReticle()); Assert.Equal(expected[0], observer.Target.Value); diff --git a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs index 328009b24..229cb042f 100644 --- a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs +++ b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs @@ -18,7 +18,7 @@ using Random = CultMath.Random; // Cut 1 (docs/locomotion-cut.md, "Restore the legacy thruster ship hulls to the live catalog"). Verifies the // content restore-hulls landed, against the real shipped catalog, read-only: the hulls exist with the expected // thruster hardpoint counts, every thruster hardpoint accepts a catalog thruster design through TryEquip at its -// own position, and the ship then moves and turns for a few ticks under MovementDirection/LookDirection on the +// own position, and the ship then moves and turns for a few ticks under MovementDirection/Turn on the // pre-Cut-3 mixer -- proof of the restored content, not of a controller this cut does not touch. public sealed class RestoredHullsTests { @@ -31,7 +31,7 @@ public sealed class RestoredHullsTests // (tools/AetherDb/AuthoredSettings.cs) is the same headless YAML reader tools/AetherDb already uses to // inspect Settings.asset outside Unity; this loads the real GameplaySettings the same way instead of // hand-copying its fields a second time. - private static GameplaySettings Settings() => AuthoredSettings.Load(FindRepoRoot()).Read("GameplaySettings"); + internal static GameplaySettings Settings() => AuthoredSettings.Load(FindRepoRoot()).Read("GameplaySettings"); // Same composition FireControlCut7Tests.OpenReadOnlyRealCatalog uses: a registry scoped to the shipped // assembly's own [CultDocument] types, so this test's own process-wide type registration never pollutes the @@ -54,7 +54,7 @@ public sealed class RestoredHullsTests throw new DirectoryNotFoundException("Run from inside the Aetheria repository."); } - private static CultCache OpenCatalog() => OpenReadOnlyRealCatalog(Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc")); + internal static CultCache OpenCatalog() => OpenReadOnlyRealCatalog(Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc")); // Shared tutorial-galaxy construction settings (Assets/Resources/Settings.asset's own values), factored out // so every test that needs a real tutorial Galaxy -- not just EntranceZoneAlwaysGetsADockedStationAcrossSeeds @@ -201,16 +201,10 @@ public sealed class RestoredHullsTests } } - // "The ship then moves and turns... for a few ticks" (docs/locomotion-cut.md verification list). Equips - // every thruster hardpoint with a fitting design, then drives MovementDirection/LookDirection the same way - // ActionGameManager's player-input path does (docs' own consumer audit) and checks Entity.Velocity and - // Entity.Direction both move over a few ticks on the current (pre-Cut-3) mixer. - [Theory] - [InlineData("Longinus")] - [InlineData("Djinni")] - public void RestoredHullMovesAndTurnsUnderMovementAndLookDirection(string name) + // A restored hull with every thruster hardpoint (and its reactor) filled, activated and warmed up: the + // rotation thrusters the helm tests need. The caller owns the cache. + internal static Ship BuildThrustedShip(CultCache cache, string name) { - using var cache = OpenCatalog(); var hull = cache.GetByName(name); var thrusterDesigns = cache.GetAll().Where(g => g.Hardpoint == HardpointType.Thruster).ToArray(); @@ -247,12 +241,27 @@ public sealed class RestoredHullsTests ship.Position = float3.zero; ship.Activate(); zone.Update(0f); // warm-up: resolves equipment stats before the driven ticks read them + return ship; + } + + // "The ship then moves and turns... for a few ticks" (docs/locomotion-cut.md verification list). Equips + // every thruster hardpoint with a fitting design, then drives MovementDirection/Turn the same way + // ActionGameManager's player-input path does (docs' own consumer audit) and checks Entity.Velocity and + // Entity.Direction both move over a few ticks on the current (pre-Cut-3) mixer. + [Theory] + [InlineData("Longinus")] + [InlineData("Djinni")] + public void RestoredHullMovesAndTurnsUnderMovementAndTurn(string name) + { + using var cache = OpenCatalog(); + var ship = BuildThrustedShip(cache, name); + var zone = ship.Zone; var startVelocity = ship.Velocity; var startDirection = ship.Direction; ship.MovementDirection = float2(0, 1); // full forward, same axis ActionGameManager.Input.Player.Move drives - ship.LookDirection = float3(1, 0, 0); // off-axis target heading, so the mixer's yaw thrusters have work to do + ship.Turn = 1; // a turn command, so the mixer's yaw thrusters have work to do for (var i = 0; i < 10; i++) zone.Update(1f / 60f); Assert.NotEqual(startVelocity, ship.Velocity); @@ -538,7 +547,6 @@ public sealed class RestoredHullsTests zone.Update(0f); ship.MovementDirection = float2(0, 1); // full forward - ship.LookDirection = float3(ship.Direction.x, 0, ship.Direction.y); // straight line: hold the ship's own heading // Soul measured this loadout's forward thrusters crossing 386 K (thermal shutdown) around 7.3 s under // real settings; 5 s keeps every tick inside the window where thrust runs continuously and unforced, so diff --git a/tests/Aetheria.Shared.Tests/ScenarioTests.cs b/tests/Aetheria.Shared.Tests/ScenarioTests.cs index 44d64db50..cfd5cc174 100644 --- a/tests/Aetheria.Shared.Tests/ScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/ScenarioTests.cs @@ -317,12 +317,12 @@ public sealed partial class RunStartTests Assert.Equal(1, AgentsOf(zone, hostile)); Assert.Equal(0, AgentsOf(zone, neutral)); Assert.Equal(0, AgentsOf(zone, player)); - var look = neutral.LookDirection; + var look = neutral.Aim; for (var tick = 0; tick < 50; tick++) { zone.Update(.1f); Assert.Equal(float2.zero, ((Ship) neutral).MovementDirection); - Assert.Equal(look, neutral.LookDirection); + Assert.Equal(look, neutral.Aim); } Assert.Equal(0, AgentsOf(zone, neutral)); } diff --git a/tests/Aetheria.Shared.Tests/SteeringTests.cs b/tests/Aetheria.Shared.Tests/SteeringTests.cs new file mode 100644 index 000000000..6d0bd5922 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SteeringTests.cs @@ -0,0 +1,177 @@ +using System; +using System.Linq; +using CultMath; +using GameCult.Caching; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; + +// The helm split: Ship.Turn is the one facing command, Steering.Toward turns a heading into it, HelmInput owns +// the player's face-the-aim state, and the hull never reads Aim. Ships are the restored Longinus with its real +// rotation thrusters, so rotation pays the thruster path the way it does in play. +public sealed class SteeringTests +{ + private const string Hull = "Longinus"; + + private static float2 Right(Ship ship) => normalize(ship.Direction).Rotate(ItemRotation.Clockwise); + + // A unit heading `degrees` clockwise of the hull's nose. + private static float2 Heading(Ship ship, float degrees) + { + var r = radians(degrees); + return normalize(ship.Direction) * cos(r) + Right(ship) * sin(r); + } + + private static float Angle(float2 from, float2 to) => acos(clamp(dot(normalize(from), normalize(to)), -1f, 1f)); + + [Fact] + public void TowardIsZeroOnTheHeading() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull); + + Assert.Equal(0f, Steering.Toward(ship, ship.Direction)); + Assert.Equal(0f, Steering.Toward(ship, Heading(ship, .5f))); + Assert.Equal(0f, Steering.Toward(ship, Heading(ship, -.5f))); + } + + [Fact] + public void TowardTurnsClockwiseForAStarboardHeading() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull); + + var expected = sqrt(sin(radians(45f))); + Assert.Equal(expected, Steering.Toward(ship, Heading(ship, 45f)), 4); + Assert.Equal(-expected, Steering.Toward(ship, Heading(ship, -45f)), 4); + } + + [Fact] + public void AHeadingBehindStillTurns() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull); + + Assert.Equal(1f, abs(Steering.Toward(ship, -ship.Direction))); + Assert.Equal(-1f, Steering.Toward(ship, Heading(ship, -170f))); + Assert.Equal(1f, Steering.Toward(ship, Heading(ship, 170f))); + } + + [Fact] + public void ANonFiniteHeadingHoldsCourse() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull); + + foreach (var heading in new[] { float2.zero, float2(float.NaN, 1), float2(1, float.NaN), float2(float.PositiveInfinity, 0) }) + { + ship.Turn = Steering.Toward(ship, heading); + Assert.Equal(0f, ship.Turn); + for (var i = 0; i < 5; i++) ship.Update(1f / 60f); + Assert.False(float.IsNaN(ship.Direction.x) || float.IsNaN(ship.Direction.y)); + } + } + + [Fact] + public void HoldAndToggleAgree() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull); + var held = new HelmInput { Held = true }; + var latched = new HelmInput(); + latched.Toggle(); + + foreach (var direction in new[] { float2(0, 1), float2(1, 0), float2(-1, -1), float2(.3f, -.9f) }) + foreach (var aim in new[] { float2(0, 1), float2(1, 1), float2(-1, 0), float2(0, -1), float2(.2f, -.7f), float2.zero }) + foreach (var axis in new[] { -1f, 0f, .5f, 1f }) + { + ship.Direction = direction; + var expected = Steering.Toward(ship, aim); + Assert.Equal(expected, held.Demand(ship, aim, axis)); + Assert.Equal(expected, latched.Demand(ship, aim, axis)); + } + } + + [Fact] + public void FaceAimOffFollowsTheAxis() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull); + var helm = new HelmInput(); + + foreach (var aim in new[] { float2(0, 1), float2(1, 0), float2(-1, -1) }) + { + Assert.Equal(.25f, helm.Demand(ship, aim, .25f)); + Assert.Equal(1f, helm.Demand(ship, aim, 3f)); + Assert.Equal(-1f, helm.Demand(ship, aim, -3f)); + } + + helm.Toggle(); + Assert.True(helm.Latched); + helm.Toggle(); + Assert.False(helm.Latched); + Assert.False(helm.FaceAim); + } + + [Fact] + public void TheHullIgnoresTheAim() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull); + var start = ship.Direction; + + ship.Turn = 0; + ship.Aim = float3(Right(ship).x, 0, Right(ship).y); + for (var i = 0; i < 60; i++) ship.Update(1f / 60f); + + Assert.True(Angle(start, ship.Direction) < radians(.01f)); + } + + [Fact] + public void TurnRotatesThroughTheThrusters() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var clockwise = RestoredHullsTests.BuildThrustedShip(cache, Hull); + var counter = RestoredHullsTests.BuildThrustedShip(cache, Hull); + var dead = RestoredHullsTests.BuildThrustedShip(cache, Hull); + var start = clockwise.Direction; + var right = Right(clockwise); + + foreach (var item in dead.Equipment) + if (item.Behaviors.Any(b => b is Thruster)) item.Enabled.Value = false; + + clockwise.Turn = 1; + counter.Turn = -1; + dead.Turn = 1; + for (var i = 0; i < 60; i++) + { + clockwise.Update(1f / 60f); + counter.Update(1f / 60f); + dead.Update(1f / 60f); + } + + Assert.True(dot(clockwise.Direction, right) > .01f); + Assert.True(dot(counter.Direction, right) < -.01f); + Assert.True(Angle(start, dead.Direction) < radians(.01f)); + } + + [Fact] + public void AccelerateWritesTurnNotAim() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull); + var agent = new Agent(ship); + var aim = float3(0, 0, 1); + ship.Aim = aim; + + agent.Accelerate(Right(ship) * 100f); + Assert.True(ship.Turn > 0f); + Assert.Equal(aim, ship.Aim); + + ship.Turn = .37f; + agent.Accelerate(-Right(ship) * 100f, true); + Assert.Equal(.37f, ship.Turn); + Assert.Equal(aim, ship.Aim); + } +} diff --git a/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs b/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs index 29bb3f954..bc6af3597 100644 --- a/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs +++ b/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs @@ -352,7 +352,7 @@ public sealed class TargetingSystemTests : IDisposable Assert.True(ship.TryEquip(targeting)); zone.Entities.Add(ship); - ship.LookDirection = float3(0, 0, 1); + ship.Aim = float3(0, 0, 1); ship.Activate(); return ship; } From 5960beb3e705a054772eae57dc88ece70280a8ac Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 12:33:22 +0200 Subject: [PATCH 02/11] Helm split, player input: A/D turn, Q/E strafe, Shift holds and Caps Lock latches face-the-aim The player writes Ship.Turn through HelmInput.Demand, and the FACE AIM mark shows while the latch is on. Bindings keep their ids so saved overrides still apply; Face Aim and Toggle Face Aim are new actions, and the wrapper is regenerated to match the asset. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Resources/Aetheria.inputactions | 218 +++++++++++++++---- Assets/Scripts/AetheriaInput.cs | 164 +++++++++++++- Assets/Scripts/Gameplay/ActionGameManager.cs | 24 +- 3 files changed, 356 insertions(+), 50 deletions(-) diff --git a/Assets/Resources/Aetheria.inputactions b/Assets/Resources/Aetheria.inputactions index 86044799b..fe1ef6086 100644 --- a/Assets/Resources/Aetheria.inputactions +++ b/Assets/Resources/Aetheria.inputactions @@ -1,5 +1,6 @@ { "name": "Aetheria", + "version": 1, "maps": [ { "name": "Player", @@ -12,7 +13,8 @@ "expectedControlType": "Vector2", "processors": "", "interactions": "", - "initialStateCheck": true + "initialStateCheck": true, + "priority": 0 }, { "name": "Look", @@ -21,7 +23,8 @@ "expectedControlType": "Vector2", "processors": "", "interactions": "", - "initialStateCheck": true + "initialStateCheck": true, + "priority": 0 }, { "name": "Minimap Zoom", @@ -30,7 +33,8 @@ "expectedControlType": "Button", "processors": "", "interactions": "", - "initialStateCheck": false + "initialStateCheck": false, + "priority": 0 }, { "name": "Target Reticle", @@ -39,7 +43,8 @@ "expectedControlType": "Button", "processors": "", "interactions": "", - "initialStateCheck": false + "initialStateCheck": false, + "priority": 0 }, { "name": "Target Previous", @@ -48,7 +53,8 @@ "expectedControlType": "Button", "processors": "", "interactions": "", - "initialStateCheck": false + "initialStateCheck": false, + "priority": 0 }, { "name": "Target Next", @@ -57,7 +63,8 @@ "expectedControlType": "Button", "processors": "", "interactions": "", - "initialStateCheck": false + "initialStateCheck": false, + "priority": 0 }, { "name": "Target Nearest", @@ -66,7 +73,8 @@ "expectedControlType": "Button", "processors": "", "interactions": "", - "initialStateCheck": false + "initialStateCheck": false, + "priority": 0 }, { "name": "Cycle Target Item", @@ -75,7 +83,8 @@ "expectedControlType": "Button", "processors": "", "interactions": "", - "initialStateCheck": false + "initialStateCheck": false, + "priority": 0 }, { "name": "Toggle Heatsinks", @@ -84,7 +93,8 @@ "expectedControlType": "Button", "processors": "", "interactions": "", - "initialStateCheck": false + "initialStateCheck": false, + "priority": 0 }, { "name": "Toggle Shield", @@ -93,7 +103,8 @@ "expectedControlType": "Button", "processors": "", "interactions": "", - "initialStateCheck": false + "initialStateCheck": false, + "priority": 0 }, { "name": "Toggle Stance", @@ -102,7 +113,8 @@ "expectedControlType": "Button", "processors": "", "interactions": "", - "initialStateCheck": false + "initialStateCheck": false, + "priority": 0 }, { "name": "Override Shutdown", @@ -111,7 +123,8 @@ "expectedControlType": "Button", "processors": "", "interactions": "", - "initialStateCheck": false + "initialStateCheck": false, + "priority": 0 }, { "name": "Ping", @@ -120,7 +133,8 @@ "expectedControlType": "Button", "processors": "", "interactions": "", - "initialStateCheck": false + "initialStateCheck": false, + "priority": 0 }, { "name": "Hide UI", @@ -129,7 +143,8 @@ "expectedControlType": "Button", "processors": "", "interactions": "", - "initialStateCheck": false + "initialStateCheck": false, + "priority": 0 }, { "name": "Tractor Beam", @@ -138,7 +153,8 @@ "expectedControlType": "", "processors": "", "interactions": "", - "initialStateCheck": true + "initialStateCheck": true, + "priority": 0 }, { "name": "Turn", @@ -147,23 +163,88 @@ "expectedControlType": "Axis", "processors": "", "interactions": "", - "initialStateCheck": true + "initialStateCheck": true, + "priority": 0 + }, + { + "name": "Face Aim", + "type": "Button", + "id": "6d5bd565-8c0e-4789-9d51-177dc0ec8e11", + "expectedControlType": "Button", + "processors": "", + "interactions": "", + "initialStateCheck": false, + "priority": 0 + }, + { + "name": "Toggle Face Aim", + "type": "Button", + "id": "4067c72f-3e3d-4d7b-a8b5-356cab9793fc", + "expectedControlType": "Button", + "processors": "", + "interactions": "", + "initialStateCheck": false, + "priority": 0 } ], "bindings": [ { - "name": "", + "name": "Gamepad Move", "id": "978bfe49-cc26-4a3d-ab7b-7d7a29327403", - "path": "/leftStick", + "path": "2DVector(mode=2)", + "interactions": "", + "processors": "", + "groups": "", + "action": "Move", + "isComposite": true, + "isPartOfComposite": false + }, + { + "name": "up", + "id": "e9c57b4d-1cd8-4b2f-93d6-1745a37cabc9", + "path": "/leftStick/up", "interactions": "", "processors": "", "groups": ";Gamepad", "action": "Move", "isComposite": false, - "isPartOfComposite": false + "isPartOfComposite": true }, { - "name": "WASD", + "name": "down", + "id": "05f19282-8364-4ae0-b5c3-d471c10601a7", + "path": "/leftStick/down", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "Move", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "left", + "id": "1ce4a07f-43af-4e82-a631-dbb43c8191d3", + "path": "/leftShoulder", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "Move", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "right", + "id": "bd1aed7e-b80b-4341-a550-4ef10bacf4b4", + "path": "/rightShoulder", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "Move", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "W S Q E", "id": "00ca640b-d935-4593-8157-c05846ea39b3", "path": "Dpad(mode=1)", "interactions": "", @@ -198,7 +279,7 @@ { "name": "left", "id": "d2581a9b-1d11-4566-b27d-b92aff5fabbc", - "path": "/a", + "path": "/q", "interactions": "", "processors": "", "groups": ";Keyboard&Mouse", @@ -209,7 +290,7 @@ { "name": "right", "id": "fcfe95b8-67b9-4526-84b5-5d0bc98d6400", - "path": "/d", + "path": "/e", "interactions": "", "processors": "", "groups": ";Keyboard&Mouse", @@ -418,7 +499,7 @@ { "name": "negative", "id": "8536ca60-1fed-4f29-8060-04d46cfffcab", - "path": "/q", + "path": "/a", "interactions": "", "processors": "", "groups": "", @@ -429,13 +510,57 @@ { "name": "positive", "id": "19c175bf-d4e6-4626-8bee-91e0c473a94b", - "path": "/e", + "path": "/d", "interactions": "", "processors": "", "groups": "", "action": "Turn", "isComposite": false, "isPartOfComposite": true + }, + { + "name": "", + "id": "fdff8f1f-0824-49bd-b09a-c1b792f16299", + "path": "/leftStick/x", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "Turn", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "1ee2fadd-3db4-45e6-8fa4-7a621ef7d165", + "path": "/leftShift", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Face Aim", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "e9520d95-0458-439b-a37c-84c45237e110", + "path": "/capsLock", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Toggle Face Aim", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "596ed367-be34-4951-9aac-d8caa57b411f", + "path": "/leftStickPress", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "Toggle Face Aim", + "isComposite": false, + "isPartOfComposite": false } ] }, @@ -450,7 +575,8 @@ "expectedControlType": "Vector2", "processors": "", "interactions": "", - "initialStateCheck": true + "initialStateCheck": true, + "priority": 0 }, { "name": "Submit", @@ -459,7 +585,8 @@ "expectedControlType": "Button", "processors": "", "interactions": "", - "initialStateCheck": false + "initialStateCheck": false, + "priority": 0 }, { "name": "Cancel", @@ -468,7 +595,8 @@ "expectedControlType": "Button", "processors": "", "interactions": "", - "initialStateCheck": false + "initialStateCheck": false, + "priority": 0 }, { "name": "Point", @@ -477,7 +605,8 @@ "expectedControlType": "Vector2", "processors": "", "interactions": "", - "initialStateCheck": false + "initialStateCheck": false, + "priority": 0 }, { "name": "Click", @@ -486,7 +615,8 @@ "expectedControlType": "Button", "processors": "", "interactions": "", - "initialStateCheck": false + "initialStateCheck": false, + "priority": 0 }, { "name": "ScrollWheel", @@ -495,7 +625,8 @@ "expectedControlType": "Vector2", "processors": "", "interactions": "", - "initialStateCheck": false + "initialStateCheck": false, + "priority": 0 }, { "name": "MiddleClick", @@ -504,7 +635,8 @@ "expectedControlType": "", "processors": "", "interactions": "", - "initialStateCheck": false + "initialStateCheck": false, + "priority": 0 }, { "name": "RightClick", @@ -513,7 +645,8 @@ "expectedControlType": "", "processors": "", "interactions": "", - "initialStateCheck": false + "initialStateCheck": false, + "priority": 0 }, { "name": "Drag", @@ -522,7 +655,8 @@ "expectedControlType": "Vector2", "processors": "", "interactions": "", - "initialStateCheck": true + "initialStateCheck": true, + "priority": 0 } ], "bindings": [ @@ -913,7 +1047,8 @@ "expectedControlType": "Button", "processors": "", "interactions": "", - "initialStateCheck": false + "initialStateCheck": false, + "priority": 0 }, { "name": "Galaxy Map", @@ -922,7 +1057,8 @@ "expectedControlType": "Button", "processors": "", "interactions": "", - "initialStateCheck": false + "initialStateCheck": false, + "priority": 0 }, { "name": "Inventory", @@ -931,7 +1067,8 @@ "expectedControlType": "Button", "processors": "", "interactions": "", - "initialStateCheck": false + "initialStateCheck": false, + "priority": 0 }, { "name": "Main Menu", @@ -940,7 +1077,8 @@ "expectedControlType": "Button", "processors": "", "interactions": "", - "initialStateCheck": false + "initialStateCheck": false, + "priority": 0 }, { "name": "Input Screen", @@ -949,7 +1087,8 @@ "expectedControlType": "Button", "processors": "", "interactions": "", - "initialStateCheck": false + "initialStateCheck": false, + "priority": 0 }, { "name": "Interact", @@ -958,7 +1097,8 @@ "expectedControlType": "Button", "processors": "", "interactions": "", - "initialStateCheck": false + "initialStateCheck": false, + "priority": 0 } ], "bindings": [ diff --git a/Assets/Scripts/AetheriaInput.cs b/Assets/Scripts/AetheriaInput.cs index 938444fef..4b5144a06 100644 --- a/Assets/Scripts/AetheriaInput.cs +++ b/Assets/Scripts/AetheriaInput.cs @@ -85,8 +85,8 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable public @AetheriaInput() { asset = InputActionAsset.FromJson(@"{ - ""version"": 1, ""name"": ""Aetheria"", + ""version"": 1, ""maps"": [ { ""name"": ""Player"", @@ -251,22 +251,86 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable ""interactions"": """", ""initialStateCheck"": true, ""priority"": 0 + }, + { + ""name"": ""Face Aim"", + ""type"": ""Button"", + ""id"": ""6d5bd565-8c0e-4789-9d51-177dc0ec8e11"", + ""expectedControlType"": ""Button"", + ""processors"": """", + ""interactions"": """", + ""initialStateCheck"": false, + ""priority"": 0 + }, + { + ""name"": ""Toggle Face Aim"", + ""type"": ""Button"", + ""id"": ""4067c72f-3e3d-4d7b-a8b5-356cab9793fc"", + ""expectedControlType"": ""Button"", + ""processors"": """", + ""interactions"": """", + ""initialStateCheck"": false, + ""priority"": 0 } ], ""bindings"": [ { - ""name"": """", + ""name"": ""Gamepad Move"", ""id"": ""978bfe49-cc26-4a3d-ab7b-7d7a29327403"", - ""path"": ""/leftStick"", + ""path"": ""2DVector(mode=2)"", + ""interactions"": """", + ""processors"": """", + ""groups"": """", + ""action"": ""Move"", + ""isComposite"": true, + ""isPartOfComposite"": false + }, + { + ""name"": ""up"", + ""id"": ""e9c57b4d-1cd8-4b2f-93d6-1745a37cabc9"", + ""path"": ""/leftStick/up"", ""interactions"": """", ""processors"": """", ""groups"": "";Gamepad"", ""action"": ""Move"", ""isComposite"": false, - ""isPartOfComposite"": false + ""isPartOfComposite"": true }, { - ""name"": ""WASD"", + ""name"": ""down"", + ""id"": ""05f19282-8364-4ae0-b5c3-d471c10601a7"", + ""path"": ""/leftStick/down"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Gamepad"", + ""action"": ""Move"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""left"", + ""id"": ""1ce4a07f-43af-4e82-a631-dbb43c8191d3"", + ""path"": ""/leftShoulder"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Gamepad"", + ""action"": ""Move"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""right"", + ""id"": ""bd1aed7e-b80b-4341-a550-4ef10bacf4b4"", + ""path"": ""/rightShoulder"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Gamepad"", + ""action"": ""Move"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""W S Q E"", ""id"": ""00ca640b-d935-4593-8157-c05846ea39b3"", ""path"": ""Dpad(mode=1)"", ""interactions"": """", @@ -301,7 +365,7 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable { ""name"": ""left"", ""id"": ""d2581a9b-1d11-4566-b27d-b92aff5fabbc"", - ""path"": ""/a"", + ""path"": ""/q"", ""interactions"": """", ""processors"": """", ""groups"": "";Keyboard&Mouse"", @@ -312,7 +376,7 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable { ""name"": ""right"", ""id"": ""fcfe95b8-67b9-4526-84b5-5d0bc98d6400"", - ""path"": ""/d"", + ""path"": ""/e"", ""interactions"": """", ""processors"": """", ""groups"": "";Keyboard&Mouse"", @@ -521,7 +585,7 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable { ""name"": ""negative"", ""id"": ""8536ca60-1fed-4f29-8060-04d46cfffcab"", - ""path"": ""/q"", + ""path"": ""/a"", ""interactions"": """", ""processors"": """", ""groups"": """", @@ -532,13 +596,57 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable { ""name"": ""positive"", ""id"": ""19c175bf-d4e6-4626-8bee-91e0c473a94b"", - ""path"": ""/e"", + ""path"": ""/d"", ""interactions"": """", ""processors"": """", ""groups"": """", ""action"": ""Turn"", ""isComposite"": false, ""isPartOfComposite"": true + }, + { + ""name"": """", + ""id"": ""fdff8f1f-0824-49bd-b09a-c1b792f16299"", + ""path"": ""/leftStick/x"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Gamepad"", + ""action"": ""Turn"", + ""isComposite"": false, + ""isPartOfComposite"": false + }, + { + ""name"": """", + ""id"": ""1ee2fadd-3db4-45e6-8fa4-7a621ef7d165"", + ""path"": ""/leftShift"", + ""interactions"": """", + ""processors"": """", + ""groups"": ""Keyboard&Mouse"", + ""action"": ""Face Aim"", + ""isComposite"": false, + ""isPartOfComposite"": false + }, + { + ""name"": """", + ""id"": ""e9520d95-0458-439b-a37c-84c45237e110"", + ""path"": ""/capsLock"", + ""interactions"": """", + ""processors"": """", + ""groups"": ""Keyboard&Mouse"", + ""action"": ""Toggle Face Aim"", + ""isComposite"": false, + ""isPartOfComposite"": false + }, + { + ""name"": """", + ""id"": ""596ed367-be34-4951-9aac-d8caa57b411f"", + ""path"": ""/leftStickPress"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Gamepad"", + ""action"": ""Toggle Face Aim"", + ""isComposite"": false, + ""isPartOfComposite"": false } ] }, @@ -1208,6 +1316,8 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable m_Player_HideUI = m_Player.FindAction("Hide UI", throwIfNotFound: true); m_Player_TractorBeam = m_Player.FindAction("Tractor Beam", throwIfNotFound: true); m_Player_Turn = m_Player.FindAction("Turn", throwIfNotFound: true); + m_Player_FaceAim = m_Player.FindAction("Face Aim", throwIfNotFound: true); + m_Player_ToggleFaceAim = m_Player.FindAction("Toggle Face Aim", throwIfNotFound: true); // UI m_UI = asset.FindActionMap("UI", throwIfNotFound: true); m_UI_Navigate = m_UI.FindAction("Navigate", throwIfNotFound: true); @@ -1325,6 +1435,8 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable private readonly InputAction m_Player_HideUI; private readonly InputAction m_Player_TractorBeam; private readonly InputAction m_Player_Turn; + private readonly InputAction m_Player_FaceAim; + private readonly InputAction m_Player_ToggleFaceAim; /// /// Provides access to input actions defined in input action map "Player". /// @@ -1401,6 +1513,14 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable /// public InputAction @Turn => m_Wrapper.m_Player_Turn; /// + /// Provides access to the underlying input action "Player/FaceAim". + /// + public InputAction @FaceAim => m_Wrapper.m_Player_FaceAim; + /// + /// Provides access to the underlying input action "Player/ToggleFaceAim". + /// + public InputAction @ToggleFaceAim => m_Wrapper.m_Player_ToggleFaceAim; + /// /// Provides access to the underlying input action map instance. /// public InputActionMap Get() { return m_Wrapper.m_Player; } @@ -1474,6 +1594,12 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable @Turn.started += instance.OnTurn; @Turn.performed += instance.OnTurn; @Turn.canceled += instance.OnTurn; + @FaceAim.started += instance.OnFaceAim; + @FaceAim.performed += instance.OnFaceAim; + @FaceAim.canceled += instance.OnFaceAim; + @ToggleFaceAim.started += instance.OnToggleFaceAim; + @ToggleFaceAim.performed += instance.OnToggleFaceAim; + @ToggleFaceAim.canceled += instance.OnToggleFaceAim; } /// @@ -1533,6 +1659,12 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable @Turn.started -= instance.OnTurn; @Turn.performed -= instance.OnTurn; @Turn.canceled -= instance.OnTurn; + @FaceAim.started -= instance.OnFaceAim; + @FaceAim.performed -= instance.OnFaceAim; + @FaceAim.canceled -= instance.OnFaceAim; + @ToggleFaceAim.started -= instance.OnToggleFaceAim; + @ToggleFaceAim.performed -= instance.OnToggleFaceAim; + @ToggleFaceAim.canceled -= instance.OnToggleFaceAim; } /// @@ -2059,6 +2191,20 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable /// /// void OnTurn(InputAction.CallbackContext context); + /// + /// Method invoked when associated input action "Face Aim" is either , or . + /// + /// + /// + /// + void OnFaceAim(InputAction.CallbackContext context); + /// + /// Method invoked when associated input action "Toggle Face Aim" is either , or . + /// + /// + /// + /// + void OnToggleFaceAim(InputAction.CallbackContext context); } /// /// Interface to implement callback methods for all input action callbacks associated with input actions defined by "UI" which allows adding and removing callbacks. diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index d65ce15c0..7972d57ea 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -192,6 +192,9 @@ public class ActionGameManager : MonoBehaviour // private ShipInput _shipInput; private float2 _entityYawPitch; private float3 _viewDirection; + // The player's face-the-aim state (Left Shift held, Caps Lock latched); Ship.Turn is its only output. + private readonly HelmInput _helm = new HelmInput(); + private TextMeshProUGUI _faceAimMark; private (HardpointData[] hardpoints, Transform[] barrels, PlaceUIElementWorldspace crosshair)[] _articulationGroups; private (LockWeapon targetLock, PlaceUIElementWorldspace indicator, Rotate spin)[] _lockingIndicators; private Dictionary _visibleHostileIndicators = new Dictionary(); @@ -290,6 +293,20 @@ public class ActionGameManager : MonoBehaviour InputDisplayLayout.Input = Input.asset; Input.Global.Enable(); + // The latch is the game's own, so it can disagree with the keyboard's Caps Lock light: show it. + Input.Player.ToggleFaceAim.performed += context => _helm.Toggle(); + var markParent = GameplayUI != null ? GameplayUI.transform : UiRoot.transform; + var markObject = new GameObject("Face Aim Mark", typeof(RectTransform)); + markObject.transform.SetParent(markParent, false); + _faceAimMark = markObject.AddComponent(); + _faceAimMark.text = "FACE AIM"; + _faceAimMark.fontSize = 24; + _faceAimMark.alignment = TextAlignmentOptions.Center; + _faceAimMark.raycastTarget = false; + _faceAimMark.rectTransform.anchorMin = _faceAimMark.rectTransform.anchorMax = new Vector2(.5f, .12f); + _faceAimMark.rectTransform.sizeDelta = new Vector2(300, 40); + _faceAimMark.enabled = false; + _zoomLevelIndex = Settings.DefaultMinimapZoom; Input.Player.MinimapZoom.performed += context => { @@ -663,7 +680,7 @@ public class ActionGameManager : MonoBehaviour if (!(CurrentEntity is Ship ship) || ship.WormholeAnimationInProgress) return; // var wormholeCameraFollow = new GameObject("Wormhole Camera Follow").transform; // wormholeCameraFollow.position = new Vector3(wormhole.Position.x, -50, wormhole.Position.y); - // wormholeCameraFollow.rotation = Quaternion.LookRotation(Vector3.down, ship.LookDirection); + // wormholeCameraFollow.rotation = Quaternion.LookRotation(Vector3.down, ship.Aim); // WormholeCamera.enabled = true; // WormholeCamera.Follow = wormholeCameraFollow; // FollowCamera.enabled = false; @@ -1281,16 +1298,19 @@ public class ActionGameManager : MonoBehaviour var sensitivity = PlayerSettings.InputSettings.Sensitivity; _entityYawPitch = float2(_entityYawPitch.x + look.x * sensitivity.x, clamp(_entityYawPitch.y + look.y * sensitivity.y, -.45f * PI, .45f * PI)); _viewDirection = mul(float3(0, 0, 1), CultMath.float3x3.Euler(float3(_entityYawPitch.yx, 0), RotationOrder.YXZ)); - CurrentEntity.LookDirection = _viewDirection; + CurrentEntity.Aim = _viewDirection; HeatstrokePost.weight = saturate(unlerp(0, Settings.GameplaySettings.SevereHeatstrokeRiskThreshold, CurrentEntity.Heatstroke)); var severeHeatstrokeLerp = saturate(unlerp(Settings.GameplaySettings.SevereHeatstrokeRiskThreshold, 1, CurrentEntity.Heatstroke)); SevereHeatstrokePost.weight = severeHeatstrokeLerp + severeHeatstrokeLerp * (1 - severeHeatstrokeLerp) * max(Settings.HeatstrokePhasingFloor, sin(Time.time * Settings.HeatstrokePhasingFrequency)); + _faceAimMark.enabled = _helm.Latched; if(CurrentEntity is Ship ship) { ship.MovementDirection = Input.Player.Move.ReadValue().ToCultMath(); + _helm.Held = Input.Player.FaceAim.IsPressed(); + ship.Turn = _helm.Demand(ship, _viewDirection.xz, Input.Player.Turn.ReadValue()); } UpdateFireControlDebug(CurrentEntity.Target.Value); From 1458dbb676eb06f2c82bc14d3a62353a8bc5133b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 12:41:33 +0200 Subject: [PATCH 03/11] SteeringTests: report the measured turn in the rotation test's failure Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/SteeringTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/SteeringTests.cs b/tests/Aetheria.Shared.Tests/SteeringTests.cs index 6d0bd5922..390cfc010 100644 --- a/tests/Aetheria.Shared.Tests/SteeringTests.cs +++ b/tests/Aetheria.Shared.Tests/SteeringTests.cs @@ -151,9 +151,9 @@ public sealed class SteeringTests dead.Update(1f / 60f); } - Assert.True(dot(clockwise.Direction, right) > .01f); - Assert.True(dot(counter.Direction, right) < -.01f); - Assert.True(Angle(start, dead.Direction) < radians(.01f)); + Assert.True(dot(clockwise.Direction, right) > 1e-3f, $"clockwise {dot(clockwise.Direction, right)}"); + Assert.True(dot(counter.Direction, right) < -1e-3f, $"counter {dot(counter.Direction, right)}"); + Assert.True(Angle(start, dead.Direction) < radians(.01f), $"dead {Angle(start, dead.Direction)}"); } [Fact] From 9beb5e9c9f889b282565d58dfe6613b103d7e589 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 12:43:29 +0200 Subject: [PATCH 04/11] SteeringTests: measure the rotation over a short window, before it can wrap Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/SteeringTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/SteeringTests.cs b/tests/Aetheria.Shared.Tests/SteeringTests.cs index 390cfc010..3a0110116 100644 --- a/tests/Aetheria.Shared.Tests/SteeringTests.cs +++ b/tests/Aetheria.Shared.Tests/SteeringTests.cs @@ -144,7 +144,7 @@ public sealed class SteeringTests clockwise.Turn = 1; counter.Turn = -1; dead.Turn = 1; - for (var i = 0; i < 60; i++) + for (var i = 0; i < 10; i++) { clockwise.Update(1f / 60f); counter.Update(1f / 60f); From 8c5eb5b7c6c0b3a1ca3f0707c0a3f2c1a1f62cb5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 12:44:21 +0200 Subject: [PATCH 05/11] Helm split: the Unity-regenerated input wrapper and the metas for Steering and HelmInput Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/AetheriaInput.cs | 2 +- Assets/Scripts/ServerShared/HelmInput.cs.meta | 2 ++ Assets/Scripts/ServerShared/Steering.cs.meta | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 Assets/Scripts/ServerShared/HelmInput.cs.meta create mode 100644 Assets/Scripts/ServerShared/Steering.cs.meta diff --git a/Assets/Scripts/AetheriaInput.cs b/Assets/Scripts/AetheriaInput.cs index 4b5144a06..373c0b8bb 100644 --- a/Assets/Scripts/AetheriaInput.cs +++ b/Assets/Scripts/AetheriaInput.cs @@ -85,8 +85,8 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable public @AetheriaInput() { asset = InputActionAsset.FromJson(@"{ - ""name"": ""Aetheria"", ""version"": 1, + ""name"": ""Aetheria"", ""maps"": [ { ""name"": ""Player"", diff --git a/Assets/Scripts/ServerShared/HelmInput.cs.meta b/Assets/Scripts/ServerShared/HelmInput.cs.meta new file mode 100644 index 000000000..400e47215 --- /dev/null +++ b/Assets/Scripts/ServerShared/HelmInput.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f863bb50e53ae30408f5ef8be1462e12 \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/Steering.cs.meta b/Assets/Scripts/ServerShared/Steering.cs.meta new file mode 100644 index 000000000..bf9a98148 --- /dev/null +++ b/Assets/Scripts/ServerShared/Steering.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8b84ac5db42877d45b7f6286cc2e2b93 \ No newline at end of file From 5681e64c5230ca0847f804546efedc2515aab768 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 14:38:15 +0200 Subject: [PATCH 06/11] SteeringTests: pin Accelerate's thresholds, its velocity-error term and the exact-astern sign Stryker survivors on the first run: the low-delta branches of Accelerate had no behavioural test, the velocity error was only exercised from rest, and astern was asserted by magnitude only. Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/SteeringTests.cs | 46 +++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/SteeringTests.cs b/tests/Aetheria.Shared.Tests/SteeringTests.cs index 3a0110116..5e0fffa6c 100644 --- a/tests/Aetheria.Shared.Tests/SteeringTests.cs +++ b/tests/Aetheria.Shared.Tests/SteeringTests.cs @@ -53,7 +53,7 @@ public sealed class SteeringTests using var cache = RestoredHullsTests.OpenCatalog(); var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull); - Assert.Equal(1f, abs(Steering.Toward(ship, -ship.Direction))); + Assert.Equal(1f, Steering.Toward(ship, -ship.Direction)); // exactly astern: no lateral component, so +1 Assert.Equal(-1f, Steering.Toward(ship, Heading(ship, -170f))); Assert.Equal(1f, Steering.Toward(ship, Heading(ship, 170f))); } @@ -174,4 +174,48 @@ public sealed class SteeringTests Assert.Equal(.37f, ship.Turn); Assert.Equal(aim, ship.Aim); } + + // The thresholds Accelerate steers by: above 20 it turns toward the velocity error, between 1 and 20 it + // thrusts sideways and leaves Turn to the caller, at or below 1 it holds still. + [Fact] + public void AccelerateThresholdsAreExact() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull); + var agent = new Agent(ship); + Assert.Equal(float2(0, 1), ship.Direction); // starboard is +x + + ship.Turn = .37f; + agent.Accelerate(float2(20, 0), false); // exactly the forward threshold: not above it + Assert.Equal(.37f, ship.Turn); + Assert.Equal(float2(1, 0), ship.MovementDirection); + + agent.Accelerate(float2(21, 0), false); + Assert.True(ship.Turn > .5f); + Assert.Equal(float2(0, 0), ship.MovementDirection); + + ship.Turn = .37f; + agent.Accelerate(float2(1, 0), false); // exactly the thrust threshold: not above it + Assert.Equal(float2.zero, ship.MovementDirection); + Assert.Equal(.37f, ship.Turn); + + agent.Accelerate(float2(5, 0), false); + Assert.Equal(float2(1, 0), ship.MovementDirection); + Assert.Equal(.37f, ship.Turn); + } + + [Fact] + public void AccelerateSteersByTheVelocityError() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull); + var agent = new Agent(ship); + ship.Velocity = float2(100, 0); + ship.Turn = .37f; + + agent.Accelerate(float2(100, 0), false); // already at the wanted velocity: nothing to correct + + Assert.Equal(.37f, ship.Turn); + Assert.Equal(float2.zero, ship.MovementDirection); + } } From 636ffc92bd55b678f36b7edf2c0ebbfa251611d7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 16:23:55 +0200 Subject: [PATCH 07/11] Agent.Update writes Turn every tick, so a state that does not pilot holds the hull Turn is a rate. Combat wrote it each tick and nothing wrote it after the Minion returned to its root state, so the hull kept the last rate and spun without end. The pilot tick now writes zero before the state runs; a piloting state overwrites it with its demand. One writer per tick, no reset in Ship. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Agents/Agent.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Assets/Scripts/ServerShared/Agents/Agent.cs b/Assets/Scripts/ServerShared/Agents/Agent.cs index 403aece7d..1d51a855f 100644 --- a/Assets/Scripts/ServerShared/Agents/Agent.cs +++ b/Assets/Scripts/ServerShared/Agents/Agent.cs @@ -46,6 +46,9 @@ public class Agent public void Update(float delta) { + // The pilot tick writes Turn every tick, in every state: a state that pilots overwrites this zero with its + // demand, and one that does not leaves the hull holding course instead of inheriting the last state's rate. + Ship.Turn = 0; _currentState.Update(delta); foreach (var transition in _currentState.Transitions) if (transition.Condition()) From b0d7ccb4f8f1a8f874827c3b99355288134308e1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 16:23:56 +0200 Subject: [PATCH 08/11] Pin the agent steering writes and the helm rules Soul found unpinned Holding heading after a state stops piloting (SteeringTests, and a Minion that loses its target), Combat leading a moving target, MoveTo's Aim and Turn in the low-delta regime, the behind-the-hull threshold, the Turn clamp, and the hold branch clearing a stale MovementDirection. Co-Authored-By: Claude Sonnet 5.5 --- .../Aetheria.Shared.Tests/FireControlTests.cs | 44 ++++++- tests/Aetheria.Shared.Tests/SteeringTests.cs | 116 ++++++++++++++++++ 2 files changed, 159 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/FireControlTests.cs b/tests/Aetheria.Shared.Tests/FireControlTests.cs index 51d03bd9f..a6d40a089 100644 --- a/tests/Aetheria.Shared.Tests/FireControlTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlTests.cs @@ -176,7 +176,7 @@ public sealed class FireControlTests : IDisposable } // A shooter with one in-range, in-arc gun, a detected target at `targetPosition`, and a Minion driving the shooter. - private (Ship shooter, Ship target, Zone zone) BuildCombatEncounter(float3 targetPosition) + private (Ship shooter, Ship target, Zone zone) BuildCombatEncounter(float3 targetPosition, float projectileSpeed = 0f) { var (items, shooter, weapons) = BuildShipWithHardpoints((ItemRotation.None, 0f)); var weaponData = (InstantWeaponData) weapons[0].Data.Behaviors.Single(b => b is InstantWeaponData); @@ -184,6 +184,7 @@ public sealed class FireControlTests : IDisposable weaponData.Cooldown = new PerformanceStat { Min = 1, Max = 1 }; weaponData.Range = new PerformanceStat { Min = 1000, Max = 1000 }; weaponData.MinRange = new PerformanceStat { Min = 0, Max = 0 }; + weaponData.Velocity = new PerformanceStat { Min = projectileSpeed, Max = projectileSpeed }; weaponData.DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }; var zone = shooter.Zone; @@ -254,4 +255,45 @@ public sealed class FireControlTests : IDisposable Assert.Equal(Steering.Toward(shooter, shooter.Aim.xz), shooter.Turn); Assert.True(abs(shooter.Turn) > .5f); } + + // Against a target that moves, Combat aims at where the round will meet it (FireControl.PredictedIntercept), + // not where the target is: the aim leads, and the hull's Turn follows the intercept bearing. + [Fact] + public void CombatLeadsAMovingTarget() + { + var (shooter, target, zone) = BuildCombatEncounter(float3(0, 0, 100), projectileSpeed: 20f); + zone.Update(1f); // transitions the Minion into CombatState and evaluates weapon stats + + // Agents update before entities, so Combat reads exactly this state on the next tick. + target.Position = float3(0, 0, 100); + target.Velocity = float2(4, 0); + var weapon = shooter.Weapons.Single(); + var intercept = normalize((FireControl.PredictedIntercept(weapon, shooter, target) - shooter.Position).xz); + var bearing = normalize(target.Position.xz - shooter.Position.xz); + Assert.True(dot(intercept, bearing) < cos(radians(5f)), "the fixture really leads the target"); + + zone.Update(1f); + + var aim = normalize(shooter.Aim.xz); + Assert.True(dot(aim, intercept) > .9999f, $"aim {shooter.Aim}"); + Assert.Equal(Steering.Toward(shooter, intercept), shooter.Turn); + Assert.True(shooter.Turn > 0f); + } + + // A Minion whose target goes away returns to its root state, which pilots nothing: the hull must stop + // turning rather than keep the rate Combat last commanded. + [Fact] + public void AMinionThatLosesItsTargetStopsTurning() + { + var (shooter, _, zone) = BuildCombatEncounter(float3(10, 0, 10)); + zone.Update(1f); + zone.Update(1f); + Assert.True(abs(shooter.Turn) > .5f, "Combat commanded a turn"); + + shooter.SetTarget(TargetRef.None); + zone.Update(1f); // Combat has no target and the Minion transitions to its root state + zone.Update(1f); + + Assert.Equal(0f, shooter.Turn); + } } diff --git a/tests/Aetheria.Shared.Tests/SteeringTests.cs b/tests/Aetheria.Shared.Tests/SteeringTests.cs index 5e0fffa6c..5d6aad5fa 100644 --- a/tests/Aetheria.Shared.Tests/SteeringTests.cs +++ b/tests/Aetheria.Shared.Tests/SteeringTests.cs @@ -195,6 +195,7 @@ public sealed class SteeringTests Assert.Equal(float2(0, 0), ship.MovementDirection); ship.Turn = .37f; + ship.MovementDirection = float2(1, 1); // a stale command the hold branch must clear agent.Accelerate(float2(1, 0), false); // exactly the thrust threshold: not above it Assert.Equal(float2.zero, ship.MovementDirection); Assert.Equal(.37f, ship.Turn); @@ -218,4 +219,119 @@ public sealed class SteeringTests Assert.Equal(.37f, ship.Turn); Assert.Equal(float2.zero, ship.MovementDirection); } + + // Behind the hull is "dot < 0": 100 degrees off the nose is already behind, so it turns at full demand, and + // 80 degrees is not, so it is the shaped lateral component. + [Fact] + public void TheBehindRuleBeginsPastNinetyDegrees() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull); + + 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))); + Assert.Equal(sqrt(sin(radians(80f))), Steering.Toward(ship, Heading(ship, 80f)), 4); + Assert.Equal(-sqrt(sin(radians(80f))), Steering.Toward(ship, Heading(ship, -80f)), 4); + } + + // Ship.Update clamps the command to -1..1: a Turn of 3 rotates the hull exactly as a Turn of 1 does. + [Fact] + public void TurnIsClampedToOne() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var one = RestoredHullsTests.BuildThrustedShip(cache, Hull); + var three = RestoredHullsTests.BuildThrustedShip(cache, Hull); + var minusOne = RestoredHullsTests.BuildThrustedShip(cache, Hull); + var minusThree = RestoredHullsTests.BuildThrustedShip(cache, Hull); + + one.Turn = 1; + three.Turn = 3; + minusOne.Turn = -1; + minusThree.Turn = -3; + for (var i = 0; i < 10; i++) + { + one.Update(1f / 60f); + three.Update(1f / 60f); + minusOne.Update(1f / 60f); + minusThree.Update(1f / 60f); + } + + Assert.True(Angle(one.Direction, Heading(one, 0f)) > radians(.05f), "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)); + } + + // A state that writes a constant turn, standing for any piloting state. + private sealed class SpinState : BaseState + { + public SpinState(Agent agent) : base(agent) { } + public override void Update(float delta) => _agent.Ship.Turn = .5f; + } + + // Turn is a rate, so a state that stops piloting must not leave the last state's rate behind: the agent's + // tick writes Turn every tick, zero when its state demands none, and the hull then holds its heading. + [Fact] + public void AnAgentWhoseStateStopsPilotingHoldsItsHeading() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull); + var agent = new Agent(ship); + var start = ship.Direction; + + agent.Transition(new SpinState(agent)); + for (var i = 0; i < 10; i++) + { + agent.Update(1f / 60f); + ship.Update(1f / 60f); + } + Assert.True(Angle(start, ship.Direction) > radians(.05f), "the piloting state turns the hull"); + Assert.Equal(.5f, ship.Turn); + + agent.Transition(new BaseState(agent)); + agent.Update(1f / 60f); + ship.Update(1f / 60f); + var held = ship.Direction; + for (var i = 0; i < 60; i++) + { + agent.Update(1f / 60f); + ship.Update(1f / 60f); + } + + Assert.Equal(0f, ship.Turn); + Assert.True(Angle(held, ship.Direction) < radians(.01f), $"{Angle(held, ship.Direction)}"); + } + + private sealed class MoveToPointState : MoveToState + { + public float2 Point; + public MoveToPointState(Agent agent) : base(agent) { } + protected override float2 TargetPosition => Point; + } + + // MoveTo aims at its target and commands the hull toward it. The ship already flies the wanted velocity, so + // Accelerate (which leaves Turn to the caller below its thresholds) writes none of it: what is measured is + // MoveTo's own write, to either side. + [Theory] + [InlineData(90f)] + [InlineData(-45f)] + public void MoveToAimsAtTheTargetAndTurnsTowardIt(float degrees) + { + using var cache = RestoredHullsTests.OpenCatalog(); + var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull); + var agent = new Agent(ship); + var state = new MoveToPointState(agent); + var dir = Heading(ship, degrees); + state.Point = dir * 500f; + ship.Velocity = dir * agent.TopSpeed; // deltaV is zero: Accelerate holds and leaves Turn alone + agent.Transition(state); + + agent.Update(1f / 60f); + + Assert.True(dot(normalize(ship.Aim.xz), dir) > .9999f, $"aim {ship.Aim}"); + Assert.True(abs(ship.Aim.y) < 1e-6f); + Assert.Equal(Steering.Toward(ship, dir), ship.Turn); + Assert.True(abs(ship.Turn) > .5f); + Assert.Equal(degrees > 0 ? 1f : -1f, sign(ship.Turn)); + } } From 6833f5aa7fe56f5a16b6954c085b930173ee9fc7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 16:35:00 +0200 Subject: [PATCH 09/11] TurnIsClampedToOne: compare against the starting heading, not the hull's own The reference-turn assertion measured the angle between the hull and itself. Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/SteeringTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/SteeringTests.cs b/tests/Aetheria.Shared.Tests/SteeringTests.cs index 5d6aad5fa..8ff0f021c 100644 --- a/tests/Aetheria.Shared.Tests/SteeringTests.cs +++ b/tests/Aetheria.Shared.Tests/SteeringTests.cs @@ -244,6 +244,7 @@ public sealed class SteeringTests var three = RestoredHullsTests.BuildThrustedShip(cache, Hull); var minusOne = RestoredHullsTests.BuildThrustedShip(cache, Hull); var minusThree = RestoredHullsTests.BuildThrustedShip(cache, Hull); + var start = one.Direction; one.Turn = 1; three.Turn = 3; @@ -257,7 +258,7 @@ public sealed class SteeringTests minusThree.Update(1f / 60f); } - Assert.True(Angle(one.Direction, Heading(one, 0f)) > radians(.05f), "the reference turn is real"); + 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)); } From 97adf07aec2bcdaa32e4470e07f0eb68b9f4741c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 17:17:49 +0200 Subject: [PATCH 10/11] TurnIsClampedToOne: say that the consumers clamp too Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/SteeringTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/SteeringTests.cs b/tests/Aetheria.Shared.Tests/SteeringTests.cs index 8ff0f021c..17daa889d 100644 --- a/tests/Aetheria.Shared.Tests/SteeringTests.cs +++ b/tests/Aetheria.Shared.Tests/SteeringTests.cs @@ -235,7 +235,8 @@ public sealed class SteeringTests Assert.Equal(-sqrt(sin(radians(80f))), Steering.Toward(ship, Heading(ship, -80f)), 4); } - // Ship.Update clamps the command to -1..1: a Turn of 3 rotates the hull exactly as a Turn of 1 does. + // 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. [Fact] public void TurnIsClampedToOne() { From b644fceb2440e7441e7be8ac4b955f4c4ef361b2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 19:11:34 +0200 Subject: [PATCH 11/11] 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 --- Assets/Scripts/ServerShared/Ship.cs | 7 +-- tests/Aetheria.Shared.Tests/SteeringTests.cs | 60 +++++++++++++++++++- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/Assets/Scripts/ServerShared/Ship.cs b/Assets/Scripts/ServerShared/Ship.cs index 875dd81be..e48bffba9 100644 --- a/Assets/Scripts/ServerShared/Ship.cs +++ b/Assets/Scripts/ServerShared/Ship.cs @@ -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); diff --git a/tests/Aetheria.Shared.Tests/SteeringTests.cs b/tests/Aetheria.Shared.Tests/SteeringTests.cs index 17daa889d..b87e91f71 100644 --- a/tests/Aetheria.Shared.Tests/SteeringTests.cs +++ b/tests/Aetheria.Shared.Tests/SteeringTests.cs @@ -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.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().ToArray(); + var nose = normalize(ship.Direction); + var right = Right(ship); + + ship.Turn = turn; + ship.Update(dt); + + float Rate(Func 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.