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; }