Merge evasion-term: evasion is changes of vector plus a per-gun mount term; authored tracking rates in the catalog; the aether drive leaves the envelope

Soul gate closed at verdict cut-evasion-term.s5 (head 473a4bcc); remaining Low
test gaps are deferred to a follow-up. Merged under operator ruling
aetheria-release:ruling:completed-cuts-merge. Carries the migrated
GameData/Aetheria.cc (LFS sha256 63250cf2...7ae25); master did not change the
catalog.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-07 22:29:51 +02:00
commit 179ab92abc
11 changed files with 1653 additions and 20 deletions

View file

@ -1401,6 +1401,7 @@ public class ActionGameManager : MonoBehaviour
$"info {d.Info:F3}/{d.InfoDemandCeiling:F3} sensor {d.PSensor:F3}\n" +
$"accuracy {d.Accuracy:F3} spread {d.PSpread:F3} hull {d.POnHull:F3}\n" +
$"precision {d.Precision:F3} tracking {d.Tracking:F1}\n" +
$"mount {d.AngularVelocity:F1}/{d.TrackingRate:F1} x{d.PMount:F3}\n" +
$"{outcomeLine}\n" +
$"{pendingLine}\n" +
lastLine;

View file

@ -39,7 +39,8 @@ public class ThrusterData : BehaviorData
public class Thruster : Behavior, IAnalogBehavior, IPowerConsumer
{
public float Thrust { get; private set; }
// Live: heat, quality, durability and power move this stat after construction, so it is read, never cached.
public float Thrust => Evaluate(_data.Thrust);
public float Torque { get; }
public float Axis
@ -67,14 +68,12 @@ public class Thruster : Behavior, IAnalogBehavior, IPowerConsumer
var itemCenter = hullData.Shape.Inset(itemData.Shape, item.Position, item.EquippableItem.Rotation).CenterOfMass;
var toCenter = hullCenter - itemCenter;
Torque = -dot(normalize(toCenter), float2(1, 0).Rotate(item.EquippableItem.Rotation));
Thrust = Evaluate(_data.Thrust);
}
public Thruster(ThrusterData data, ConsumableItemEffect item) : base(data, item)
{
_data = data;
Torque = 0;
Thrust = Evaluate(_data.Thrust);
}
// Cut 3 (docs/stats-and-power-cut.md): the resolved stat times the behaviour-supplied throttle scalar (§1.2),
@ -91,6 +90,29 @@ public class Thruster : Behavior, IAnalogBehavior, IPowerConsumer
// under brownout is an inconvenience, not the cascading failure a starved radiator or shield causes.
public int DefaultPowerTier => PowerTiers.Medium;
// What this thruster adds to the ship's manoeuvre envelope: Execute's own arithmetic at full input, read live.
// Its push is Thrust / Mass along its mount, into the matching body
// axis, even for a flank thruster that only turns (it pushes whenever it turns); its turn is |Torque| * Thrust *
// TorqueMultiplier / Mass into the side its Torque sign fires on, when |Torque| clears the floor. Zero when the
// item is offline or unpowered, the same gates Execute applies.
public ManoeuvreEnvelope Manoeuvre()
{
if (Item == null || !Item.Active.Value || Item.PowerSupply <= 1e-4f) return default;
var settings = ItemManager.GameplaySettings;
var thrust = Thrust;
var acceleration = thrust / Entity.Mass;
var forward = normalize(Entity.Direction);
var right = forward.Rotate(ItemRotation.Clockwise);
var push = -forward.Rotate(Item.EquippableItem.Rotation);
var along = dot(push, forward);
var across = dot(push, right);
var turn = abs(Torque) > settings.TorqueFloor ? abs(Torque) * thrust * settings.TorqueMultiplier / Entity.Mass : 0f;
return new ManoeuvreEnvelope(
max(along, 0f) * acceleration, max(-along, 0f) * acceleration,
max(-across, 0f) * acceleration, max(across, 0f) * acceleration,
Torque > 0f ? turn : 0f, Torque < 0f ? turn : 0f);
}
public override bool Execute(float dt)
{
Item.SetAudioParameter(SpecialAudioParameter.Intensity, _input);
@ -101,10 +123,10 @@ public class Thruster : Behavior, IAnalogBehavior, IPowerConsumer
// has not been cut to true zero supply (the epsilon PowerBus itself already treats as "nothing granted").
if(_input > .01f && Item.PowerSupply > 1e-4f)
{
Thrust = Evaluate(_data.Thrust);
Entity.Velocity -= Direction.xz * _input * Thrust / Entity.Mass * dt;
var thrust = Thrust;
Entity.Velocity -= Direction.xz * _input * thrust / Entity.Mass * dt;
Entity.Direction = mul(Entity.Direction,
CultMath.float2x2.Rotate(_input * Torque * Thrust * ItemManager.GameplaySettings.TorqueMultiplier / Entity.Mass * dt));
CultMath.float2x2.Rotate(_input * Torque * thrust * ItemManager.GameplaySettings.TorqueMultiplier / Entity.Mass * dt));
AddHeat(_input * Evaluate(_data.Heat) * dt);
var vis = _input * Evaluate(_data.Visibility);
if (!Entity.VisibilitySources.ContainsKey(this) || vis > Entity.VisibilitySources[this])

View file

@ -45,7 +45,11 @@ public class VelocityLimit : Behavior
{
Limit = Evaluate(_data.TopSpeed);
if (length(Entity.Velocity) > Limit)
Entity.Velocity = normalize(Entity.Velocity) * Limit;
{
var clamped = normalize(Entity.Velocity) * Limit;
Entity.LimitClamp += Entity.Velocity - clamped;
Entity.Velocity = clamped;
}
return true;
}
}

View file

@ -66,6 +66,11 @@ public abstract class WeaponData : BehaviorData
[Inspectable, JsonProperty("velocity"), Key(16)]
public PerformanceStat Velocity = new PerformanceStat();
// How fast the mount follows a target sweeping the shooter's sky, in degrees per second, authored per gun (ruling
// weapon-tracking-authored). Unauthored is a mount that follows anything: FireControl.PMount reads +infinity as 1.
[Inspectable, JsonProperty("tracking"), Key(33), RuntimeInspectable]
public PerformanceStat Tracking = new PerformanceStat { Min = float.PositiveInfinity, Max = float.PositiveInfinity };
}
public abstract class Weapon : Behavior, IActivatedBehavior
@ -87,6 +92,7 @@ public abstract class Weapon : Behavior, IActivatedBehavior
public float Visibility { get; protected set; }
public float Spread { get; protected set; }
public float Velocity { get; protected set; }
public float Tracking { get; protected set; }
// Mining Cut 3 fix (Q12 A, "launchers cannot mine"): whether this weapon can mine a chunk. The one mining
// predicate: chunk reach (Entity.VisibleChunksInReach) reads it, and so does anything that decides a weapon
@ -138,6 +144,7 @@ public abstract class Weapon : Behavior, IActivatedBehavior
Visibility = Evaluate(_data.Visibility);
Spread = Evaluate(_data.Spread);
Velocity = Evaluate(_data.Velocity);
Tracking = Evaluate(_data.Tracking);
}
public override bool Execute(float dt)

View file

@ -22,7 +22,18 @@ public abstract class Entity
public float3 Position;
public float2 Direction = float2(0,1);
public float2 Velocity;
// Runtime only, never saved; Entity.Update is the one writer of both. Acceleration is the planar velocity change
// across the base update over its delta (thrust, not the drag or gravity a subclass applies before calling it),
// and Manoeuvre is what that acceleration says about how unpredictably this entity changes its motion.
public float2 Acceleration;
public ManoeuvreTrack Manoeuvre;
// The planar velocity a VelocityLimit removed during the current base update. A limit is the ship's ceiling, not a
// change of vector the pilot made, so Acceleration reads the velocity change with this put back. VelocityLimit is
// the one writer, Entity.Update zeroes it, and it is never saved.
public float2 LimitClamp;
public float[,] Temperature;
public float[,] NewTemperature;
public bool2[,] HullConductivity;
@ -1345,6 +1356,8 @@ public abstract class Entity
{
if (!_active) return;
var velocityBefore = Velocity;
LimitClamp = float2(0, 0);
var hullData = ItemManager.GetData(Hull) as HullData;
// Mining Cut 3: a chunk target is lost the moment this entity can no longer see it -- the lost-track rule
@ -1460,6 +1473,9 @@ public abstract class Entity
Velocity = Parent.Velocity;
}
else Position.y = Zone.GetHeight(Position.xz) + hullData.GridOffset;
Acceleration = delta > 0f ? (Velocity + LimitClamp - velocityBefore) / delta : float2(0, 0);
Manoeuvre.Observe(Acceleration, delta, ItemManager.GameplaySettings.SolutionWindow);
}
private void UpdateTemperature(float delta)

View file

@ -198,6 +198,31 @@ public static class FireControl
return system != null && system.Item.Active.Value ? system.Tracking : entity.ItemManager.GameplaySettings.UnaidedTracking;
}
// Two trackings, two units. Tracking(Entity) above is the ship's targeting gear in METRES: how much changing of
// vector a shooter forgives (PDeviation's tolerance). TrackingRate is a RATE in degrees per second: how fast the
// gun's mount can swing, and it alone prices the angular term. The ship's metres never forgive the mount and the
// gun's rate never forgives the jink (ruling jink-per-ship).
//
// How fast the target sweeps across the shooter's sky, in degrees per second: the component of the relative
// velocity transverse to the line of sight over the range, floored at one metre so a target on top of the
// shooter does not read infinite. Planar, allocation-free, and the one place this angle is computed.
public static float AngularVelocity(float2 offset, float2 relativeVelocity)
{
var range = max(length(offset), 1f);
return degrees(abs(offset.x * relativeVelocity.y - offset.y * relativeVelocity.x) / (range * range));
}
public static float AngularVelocity(Entity source, Entity target) =>
AngularVelocity((target.Position - source.Position).xz, target.Velocity - source.Velocity);
// The gun's rate times the ship's gear over the unaided baseline (ruling tracking-per-gun, "gun times ship"): the
// one product of the two. An unauthored gun is +infinity and so is its rate whatever the gear reads: a mount with no
// limit follows anything, and infinity times a gear that reads zero is not a number.
public static float TrackingRate(Weapon weapon, Entity source) =>
float.IsPositiveInfinity(weapon.Tracking)
? weapon.Tracking
: weapon.Tracking * Tracking(source) / source.ItemManager.GameplaySettings.UnaidedTracking;
// Cut 3, the risk this map names explicitly: Combat.cs used to run its own first_order_intercept call to
// aim, while the roll measured deviation against a separately-computed prediction -- two functions
// claiming to answer the same question will eventually disagree, and an AI would then aim at one point and
@ -296,9 +321,13 @@ public static class FireControl
// and to CellsOf, out of this cut's scope. Cut 10's own guard is what actually matters
// operationally: HitProbability and CommitProbability both bail before Forecast is ever called for the
// common gated-out case (GatedOutHitProbabilityAllocatesNothing pins that at exactly 0).
private static (Silhouette Silhouette, float PSpread) Forecast(Weapon weapon, Entity source, Entity target, HullData targetHull, float precision, float range)
// Evasion fix (evasion-term): the same call also prices how far the target can and does move off the line of
// fire, along the same travel direction, so the live price and the HUD read one evasion.
private static (Silhouette Silhouette, float PSpread, float Evasion) Forecast(Weapon weapon, Entity source, Entity target, HullData targetHull, float precision, float range)
{
var bearing = Bearing(target, TravelDirection(weapon, source, target));
var travel = TravelDirection(weapon, source, target);
var evasion = Evasion(target, travel);
var bearing = Bearing(target, travel);
var buffer = ArrayPool<Interval>.Shared.Rent(targetHull.Shape.Coordinates.Length);
float pSpread;
Silhouette sil;
@ -315,7 +344,7 @@ public static class FireControl
// sil.Intervals is the returned buffer -- cleared here so nothing outside this function can read it
// after it goes back to the pool and another caller starts overwriting it.
sil.Intervals = null;
return (sil, pSpread);
return (sil, pSpread, evasion);
}
public static float HitProbability(Weapon weapon, Entity source, Entity target)
@ -324,8 +353,8 @@ public static class FireControl
if (pFire <= 0f) return 0f;
var targetHull = source.ItemManager.GetData(target.Hull) as HullData;
var (sil, pSpread) = Forecast(weapon, source, target, targetHull, Precision(source), range);
return pFire * pSpread * sil.POnHull;
var (sil, pSpread, evasion) = Forecast(weapon, source, target, targetHull, Precision(source), range);
return pFire * PMount(AngularVelocity(source, target), TrackingRate(weapon, source)) * pSpread * sil.POnHull * PDeviation(evasion, Tracking(source));
}
// Presentation only: the debug HUD's view of exactly the factors HitProbability multiplies. It computes
@ -362,9 +391,14 @@ public static class FireControl
diagnostic.PSensor = PSensor(settings, diagnostic.Resolution, diagnostic.Info);
diagnostic.PFire = diagnostic.Accuracy * diagnostic.PSensor;
var (sil, pSpread) = Forecast(weapon, source, target, targetHull, diagnostic.Precision, diagnostic.Range);
var (sil, pSpread, evasion) = Forecast(weapon, source, target, targetHull, diagnostic.Precision, diagnostic.Range);
diagnostic.PSpread = pSpread;
diagnostic.POnHull = sil.POnHull;
diagnostic.Evasion = evasion;
diagnostic.PEvasion = PDeviation(evasion, diagnostic.Tracking);
diagnostic.AngularVelocity = AngularVelocity(source, target);
diagnostic.TrackingRate = TrackingRate(weapon, source);
diagnostic.PMount = PMount(diagnostic.AngularVelocity, diagnostic.TrackingRate);
diagnostic.PBase = HitProbability(weapon, source, target);
return diagnostic;
@ -427,12 +461,118 @@ public static class FireControl
return new Interval { Lo = centre - h, Hi = centre + h };
}
// The one deviation shape: the share of a shot that survives `deviation` metres of miss against a shooter that
// forgives `tolerance` metres. Every deviation-like price (flight, evasion, a missile's miss) goes through it.
public static float PDeviation(float deviation, float tolerance) =>
tolerance <= 0f ? (deviation <= 0f ? 1f : 0f) : saturate(1f - deviation / tolerance);
// The mount's factor: the share of a shot that survives a target sweeping across the sky at `angularVelocity`
// degrees per second against a gun whose mount follows `rate` degrees per second. Half at the rate, a sixteenth at
// twice it: 0.5^((omega/rate)^2). An infinite rate (an unauthored gun) gives exactly 1; a rate of zero follows
// nothing that moves.
public static float PMount(float angularVelocity, float rate)
{
if (rate <= 0f) return angularVelocity <= 0f ? 1f : 0f;
var ratio = angularVelocity / rate;
return pow(.5f, ratio * ratio);
}
// The commit price's deviation: what the target has already moved off the frozen forecast, plus what it can and
// does move off the line of fire (Evasion). The second part applies at any flight time, so an instant weapon
// (flight time 0, realized deviation 0) still feels a jinking target.
public static float DeviationProbability(PendingShot shot, float now, out float deviation)
{
var elapsed = now - shot.FireTime;
var predicted = shot.FireTargetPosition + shot.FireTargetVelocity * elapsed;
deviation = shot.Target == null ? 0f : length((shot.Target.Position - predicted).xz);
return shot.Target == null ? 1f : saturate(1f - deviation / shot.Tracking);
deviation = shot.Target == null ? 0f : length((shot.Target.Position - predicted).xz) + Evasion(shot.Target, shot.TravelDirection);
return shot.Target == null ? 1f : PDeviation(deviation, shot.Tracking);
}
// How far a target can and does move off the line of fire in the solution window: the term DeviationProbability
// and HitProbability both add. Behaviour (its observed unpredictability across the line of sight) realises the
// evasion and capability (its envelope's reach) caps it, so a coasting ship evades nothing whatever it could do
// and a steady burn or a dither much faster than the window averages out. A target that is not a Ship has no
// envelope. This is the one call guns and munitions make.
public static float Evasion(Entity target, float2 lineOfSight)
{
var ship = target as Ship;
return ship == null
? 0f
: Evasion(ship.Envelope, ship.Direction, ship.Manoeuvre, lineOfSight, target.ItemManager.GameplaySettings.SolutionWindow);
}
// The pure core, allocation-free. n is perpendicular to the line of sight; u is the half-width the observed
// innovation spreads over the window (0.5 * rms * T^2); the answer is that spread, capped by the half-width of
// what the envelope can reach across the line of sight. Zero spread returns before any reach is evaluated.
public static float Evasion(in ManoeuvreEnvelope envelope, float2 heading, in ManoeuvreTrack track, float2 lineOfSight, float window)
{
var los = lengthsq(lineOfSight) < 1e-12f ? float2(0, 1) : normalize(lineOfSight);
var n = float2(-los.y, los.x);
var spread = .5f * track.LateralRms(n) * window * window;
if (spread <= 0f) return 0f;
return min(.5f * (Reach(envelope, heading, n, window) + Reach(envelope, heading, -n, window)), spread);
}
// The largest displacement from the coasting path along `direction` within `window`, for a ship that turns
// kinematically (controls-turn-inertia) and thrusts within its body-frame box. Eight faces (the four thrust axes
// and the four corners of the box), each turned toward the direction at full rate and thrusting whenever it
// points within 90 degrees of it, then held; and the no-turn support of the box. Matched a brute-force search
// within 0.1% (map M26).
public static float Reach(in ManoeuvreEnvelope e, float2 heading, float2 direction, float window)
{
if (lengthsq(heading) < 1e-12f || lengthsq(direction) < 1e-12f) return 0f;
var forward = normalize(heading);
var right = forward.Rotate(ItemRotation.Clockwise);
var wanted = normalize(direction);
var beta = atan2(dot(wanted, right), dot(wanted, forward));
// The box's support along the wanted direction with no turning: thrust is always free to fire the right sign.
var c = cos(beta);
var s = sin(beta);
var best = .5f * ((c > 0f ? e.Forward * c : -e.Reverse * c) + (s > 0f ? e.Right * s : -e.Left * s)) * window * window;
best = max(best, ReachFace(e.Forward, 0f, beta, e, window));
best = max(best, ReachFace(e.Reverse, MathF.PI, beta, e, window));
best = max(best, ReachFace(e.Right, MathF.PI / 2f, beta, e, window));
best = max(best, ReachFace(e.Left, -MathF.PI / 2f, beta, e, window));
best = max(best, ReachFace(sqrt(e.Forward * e.Forward + e.Right * e.Right), atan2(e.Right, e.Forward), beta, e, window));
best = max(best, ReachFace(sqrt(e.Forward * e.Forward + e.Left * e.Left), atan2(-e.Left, e.Forward), beta, e, window));
best = max(best, ReachFace(sqrt(e.Reverse * e.Reverse + e.Right * e.Right), atan2(e.Right, -e.Reverse), beta, e, window));
best = max(best, ReachFace(sqrt(e.Reverse * e.Reverse + e.Left * e.Left), atan2(-e.Left, -e.Reverse), beta, e, window));
return best;
}
// One face of acceleration `a`, at body angle `faceAngle` (clockwise from forward), turned to point along the
// wanted direction at body angle `beta`, at the rate its side of the envelope allows; thrust only while the face
// points within 90 degrees of the wanted direction, so the integrand is (T - t) a cos(angle between).
private static float ReachFace(float a, float faceAngle, float beta, in ManoeuvreEnvelope e, float window)
{
if (a <= 0f) return 0f;
var turn = Wrap(beta - faceAngle);
var rate = turn > 0f ? e.Clockwise : e.CounterClockwise;
var phi0 = abs(turn);
if (rate <= 1e-4f) return phi0 <= 1e-6f ? .5f * a * window * window : 0f;
var turnTime = phi0 / rate;
var onTime = phi0 <= MathF.PI / 2f ? 0f : (phi0 - MathF.PI / 2f) / rate;
if (onTime >= window) return 0f;
var endTime = min(turnTime, window);
float Integral(float t)
{
var u = phi0 - rate * t;
return -(window - t) * sin(u) / rate - cos(u) / (rate * rate);
}
var turning = endTime > onTime ? a * (Integral(endTime) - Integral(onTime)) : 0f;
var holding = turnTime < window ? .5f * a * (window - turnTime) * (window - turnTime) : 0f;
return turning + holding;
}
private static float Wrap(float angle)
{
while (angle > MathF.PI) angle -= 2f * MathF.PI;
while (angle <= -MathF.PI) angle += 2f * MathF.PI;
return angle;
}
// The one decision about what a round does, made from the shooter's state and a target and nothing else:
@ -550,7 +690,8 @@ public static class FireControl
// Solve is the one range and the one burst decision Inspect and the weapons' refusal check read too.
var solution = Solve(weapon, item, source, target);
if (solution.Outcome == FireOutcome.Refused) return 0;
var pFire = solution.PFire;
// The mount factor is frozen with the shooter-side probability: nothing at commit time re-reads the sweep.
var pFire = solution.PFire * (solution.Engaged != null ? PMount(AngularVelocity(source, solution.Engaged), TrackingRate(weapon, source)) : 1f);
var fireRange = solution.FireRange;
var engaged = solution.Engaged;
var travelDirection = solution.TravelDirection;
@ -705,7 +846,7 @@ public static class FireControl
}
}
// R3/R4: the one roll. p_base was frozen at fire; the only thing measured live is how far the target has
// R3/R4: the one roll. p_base was frozen at fire, PFire carrying the mount factor; the only thing measured live is how far the target has
// actually strayed, by now, from where a straight-line projection of its fire-time velocity said it would
// be -- forgiven by the targeting system's Tracking, likewise frozen at fire.
//
@ -1887,6 +2028,14 @@ public struct FireControlDiagnostic
public float PFire;
public float PSpread;
public float POnHull;
// The target's evasion along the shot (metres) and the share of the shot that survives it at this shooter's Tracking.
public float Evasion;
public float PEvasion;
// The mount: how fast the target sweeps the shooter's sky (degrees per second), the rate this gun on this ship
// follows (degrees per second) and the share of the shot that survives it.
public float AngularVelocity;
public float TrackingRate;
public float PMount;
public float PBase;
// What a round from this weapon does (Solve): the forecast for a fused weapon, whose percentages above do
// not describe it. BurstReach is read only when Outcome is Burst.
@ -1894,6 +2043,70 @@ public struct FireControlDiagnostic
public float BurstReach;
}
// What a ship can do at this instant, in its own frame: acceleration along each body axis (m/s^2) and the kinematic
// turn rate each way (rad/s; turning toward the ship's right is Clockwise). Each propulsor reports its share with
// its own Execute arithmetic and Ship sums them; nothing stores it. Zero is the envelope of anything that is not a Ship.
public readonly struct ManoeuvreEnvelope
{
public readonly float Forward, Reverse, Left, Right, Clockwise, CounterClockwise;
public ManoeuvreEnvelope(float forward, float reverse, float left, float right, float clockwise, float counterClockwise)
{
Forward = forward;
Reverse = reverse;
Left = left;
Right = right;
Clockwise = clockwise;
CounterClockwise = counterClockwise;
}
public static ManoeuvreEnvelope operator +(ManoeuvreEnvelope a, ManoeuvreEnvelope b) => new ManoeuvreEnvelope(
a.Forward + b.Forward, a.Reverse + b.Reverse, a.Left + b.Left, a.Right + b.Right,
a.Clockwise + b.Clockwise, a.CounterClockwise + b.CounterClockwise);
}
// How unpredictably an entity is changing its motion, observed from its measured acceleration. The trend is an
// exponential average of the acceleration, the innovation is what departs from it (low-passed at a quarter of the
// window), and the moments are the exponential average of that innovation's outer product. A steady burn and
// coasting have no innovation; a weave at the window's scale does; dithering much faster than the window averages out.
// A change of vector is an acceleration that differs from the last observation's by more than ChangeThreshold. When
// none has happened for a whole window the unpredictability is over: the track forgets it outright (the moments
// return to exactly zero and the trend settles on the steady acceleration), so a ship that stops jinking stops
// evading within the window and the zero-spread early return is reachable again.
public struct ManoeuvreTrack
{
public const float ChangeThreshold = 0.01f;
public float2 Trend;
public float2 Innovation;
public float3 Moments;
public float2 Last;
public float Quiet;
public void Observe(float2 acceleration, float dt, float window)
{
if (dt <= 0f || window <= 0f) return;
var changed = abs(acceleration.x - Last.x) > ChangeThreshold || abs(acceleration.y - Last.y) > ChangeThreshold;
Quiet = changed ? 0f : Quiet + dt;
Last = acceleration;
if (Quiet >= window)
{
Trend = acceleration;
Innovation = float2(0, 0);
Moments = float3(0, 0, 0);
return;
}
var k = 1f - exp(-dt / window);
var k4 = 1f - exp(-4f * dt / window);
Trend += k * (acceleration - Trend);
Innovation += k4 * ((acceleration - Trend) - Innovation);
Moments += k * (float3(Innovation.x * Innovation.x, Innovation.x * Innovation.y, Innovation.y * Innovation.y) - Moments);
}
// The root-mean-square innovation across the axis n (a unit vector).
public float LateralRms(float2 n) => sqrt(max(0f, n.x * n.x * Moments.x + 2f * n.x * n.y * Moments.y + n.y * n.y * Moments.z));
}
// Cut 12.2 (docs/fire-control-cut.md): a merged interval of a hull's lateral shadow, in the schematic frame's
// lateral (ell) coordinate.
// S3 fix batch (Hands, 2026-09-25): IComparable<Interval> (sorted by Lo) instead of a separate IComparer<T> --

View file

@ -259,6 +259,11 @@ public class GameplaySettings
// the result is frozen and the rest of the flight is pure choreography.
public float CommitHorizon = .5f;
// The window of fire control's solution: how far ahead a target's reach counts (Reach, Evasion) and the time
// constant of the manoeuvre track that observes it. A fire-control constant, not a weapon or missile stat. First
// guess; the operator rules the real figure in play.
public float SolutionWindow = .75f;
// World units per hull-schematic cell, used only by FireControl.HitProbability's angular-size term
// (pSpread) to turn a hull's cell footprint into a real-world silhouette size at range.
public float SchematicCellSize = 2f;

View file

@ -147,8 +147,21 @@ public class Ship : Entity
#region ThrustCalculation
// What this ship can do at this instant (see ManoeuvreEnvelope): the sum of what each live propulsor reports with
// its own Execute arithmetic; thrusters are its only propulsors. Derived each update, never saved. The aggregates above are not an envelope (they
// mix units and cancel a lone off-axis strafer), so nothing in it reads them.
public ManoeuvreEnvelope Envelope { get; private set; }
private void RecalculateEnvelope()
{
var envelope = default(ManoeuvreEnvelope);
foreach (var thruster in _allThrusters) envelope += thruster.Manoeuvre();
Envelope = envelope;
}
private void RecalculateThrust()
{
RecalculateEnvelope();
RecalculateForwardThrust();
RecalculateReverseThrust();
RecalculateLeftStrafeThrust();

BIN
GameData/Aetheria.cc (Stored with Git LFS)

Binary file not shown.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,326 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System;
using System.Collections.Generic;
using System.Linq;
using CultMath;
using Xunit;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
// The mount term (docs/aetheria-release-map.md, "Tracking per gun"): how fast a gun's mount can follow a target
// sweeping across the shooter's sky, one factor of every hit price. Rulings tracking-per-gun ("gun times ship"),
// weapon-tracking-authored (a rate is authored per gun, never derived from its footprint), jink-per-ship and
// evasion-angular-velocity. Reuses EvasionTermTests' fixtures: Longinus gun platforms and a generated Djinni.
public sealed partial class RunStartTests
{
private static PerformanceStat EvRate(float rate) => new PerformanceStat { Min = rate, Max = rate };
// The gun's rate, set on its design the way an author sets it, flown two updates so the weapon reads it.
private static void EvSetRate(Weapon weapon, Ship shooter, float rate)
{
weapon.WeaponData.Tracking = EvRate(rate);
shooter.Update(EvDt);
shooter.Update(EvDt);
Assert.Equal(rate, weapon.Tracking);
}
// The range fixture with the shooter's projectile gun at one rate and its laser at another, the target seen. The
// shooter is put back at the origin at rest, so the geometry is exactly what the test says.
private EvRange EvMountRange(float gunRate, float laserRate)
{
var range = EvRangeLaunch();
EvSetRate(range.Gun, range.Gunner, gunRate);
EvSetRate(range.GunnerLaser, range.Gunner, laserRate);
range.Gunner.Position = float3(0, range.Gunner.Position.y, 0);
range.Gunner.Velocity = float2(0, 0);
Assert.True(range.Gunner.VisibleEntities.Contains(range.Target), "fixture: the shooter still sees the target");
return range;
}
// The two guns of the one shooter: same gear, same ship, so only the gun's own rate differs.
private static (Weapon weapon, Ship shooter)[] EvGuns(EvRange range) => new[] { (range.Gun, range.Gunner), (range.GunnerLaser, range.Gunner) };
[Fact]
public void AngularVelocityIsTransverseOverRange()
{
// 150 m/s across a thousand metres is 0.15 rad/s, whichever way across, whichever way the line of sight points.
var expected = degrees(.15f);
Assert.Equal(expected, FireControl.AngularVelocity(float2(1000, 0), float2(0, 150)), 3);
Assert.Equal(expected, FireControl.AngularVelocity(float2(1000, 0), float2(0, -150)), 3);
Assert.Equal(expected, FireControl.AngularVelocity(float2(0, 1000), float2(150, 0)), 3);
Assert.Equal(expected, FireControl.AngularVelocity(float2(-1000, 0), float2(0, 150)), 3);
var angle = radians(37f);
float2 Turn(float2 v) => float2(v.x * cos(angle) - v.y * sin(angle), v.x * sin(angle) + v.y * cos(angle));
Assert.Equal(expected, FireControl.AngularVelocity(Turn(float2(1000, 0)), Turn(float2(0, 150))), 3);
// Only the transverse part counts: radial velocity is no sweep at all, and a general case reads both terms.
Assert.Equal(0f, FireControl.AngularVelocity(float2(1000, 0), float2(150, 0)), 4);
Assert.Equal(degrees(17000f / 250000f), FireControl.AngularVelocity(float2(300, 400), float2(-20, 30)), 3);
// A target nearer than a metre is read at a metre: 1 m/s across half a metre is 0.5 rad/s, not 2.
Assert.Equal(degrees(.5f), FireControl.AngularVelocity(float2(.5f, 0), float2(0, 1)), 3);
// The entity overload reads the relative velocity, planar: a common velocity is no sweep, and the shooter's own
// motion is subtracted from the target's.
var (_, source, target) = EvFleet();
source.Position = float3(0, 0, 0);
target.Position = float3(1000, 0, 0);
source.Velocity = float2(30, 40);
target.Velocity = float2(30, 40);
Assert.Equal(0f, FireControl.AngularVelocity(source, target), 4);
// Wherever the pair stands: the offset is target minus source.
source.Position = float3(500, 0, 300);
target.Position = float3(1500, 0, 300);
source.Velocity = float2(0, 100);
target.Velocity = float2(0, 250);
Assert.Equal(expected, FireControl.AngularVelocity(source, target), 3);
}
[Fact]
public void TheMountHitsHalfAtItsRate()
{
foreach (var rate in new[] { .5f, 3f, 40f })
{
Assert.Equal(.5f, FireControl.PMount(rate, rate), 6);
Assert.Equal(1f, FireControl.PMount(0f, rate));
Assert.Equal(1f / 16f, FireControl.PMount(2 * rate, rate), 6);
Assert.Equal(1f, FireControl.PMount(rate, float.PositiveInfinity));
}
Assert.Equal(1f, FireControl.PMount(1e6f, float.PositiveInfinity));
// A mount that follows nothing hits only what stands still.
Assert.Equal(0f, FireControl.PMount(1f, 0f));
Assert.Equal(1f, FireControl.PMount(0f, 0f));
Assert.Equal(0f, FireControl.PMount(1f, -2f));
var previous = float.PositiveInfinity;
for (var omega = 0f; omega <= 5f; omega += .25f)
{
var p = FireControl.PMount(omega, 1.5f);
Assert.True(p < previous, $"PMount falls as the sweep rises: {p} after {previous} at {omega}");
previous = p;
}
}
// Ruling tracking-per-gun, "gun times ship": the rate is the gun's authored rate times the shooter's gear over the
// unaided baseline, and nothing else multiplies the two.
[Fact]
public void TrackingRateIsGunTimesShip()
{
var range = EvMountRange(40f, 3f);
var unaided = _items.GameplaySettings.UnaidedTracking;
foreach (var (weapon, shooter) in EvGuns(range))
{
var gear = FireControl.Tracking(shooter);
Assert.True(gear > unaided * 1.2f, $"fixture: the shooter's targeting gear tracks better than unaided ({gear} against {unaided})");
var expected = weapon.Tracking * gear / unaided;
Assert.InRange(FireControl.TrackingRate(weapon, shooter), expected * .999f, expected * 1.001f);
}
var with = EvGuns(range).Select(g => FireControl.TrackingRate(g.weapon, g.shooter)).ToArray();
// The gear off: both guns' rates fall by the same ratio, and the ratio between the guns does not move.
foreach (var (_, shooter) in EvGuns(range))
{
shooter.GetBehavior<TargetingSystem>().Item.EquippableItem.Durability = 0f;
shooter.Update(EvDt);
shooter.Update(EvDt);
Assert.Equal(unaided, FireControl.Tracking(shooter));
}
var without = EvGuns(range).Select(g => FireControl.TrackingRate(g.weapon, g.shooter)).ToArray();
Assert.Equal(40f, without[0], 3);
Assert.Equal(3f, without[1], 3);
Assert.Equal(with[0] / without[0], with[1] / without[1], 3);
Assert.True(with[0] / without[0] > 1.2f);
Assert.Equal(with[0] / with[1], without[0] / without[1], 3);
}
// Ruling weapon-tracking-authored, in play: a fast crosser close in is beyond a heavy gun and within a light one.
[Fact]
public void LightAndHeavyGunsSplitOnAFastCloseCrosser()
{
var range = EvMountRange(40f, 3f);
EvCross(range, jinking: false, float2(100, 0));
var unaided = _items.GameplaySettings.UnaidedTracking;
var omega = degrees(100f / 200f);
var mounts = new List<float>();
foreach (var (weapon, shooter) in EvGuns(range))
{
var d = FireControl.Inspect(weapon, shooter, range.Target);
var rate = weapon.Tracking * FireControl.Tracking(shooter) / unaided;
Assert.InRange(d.AngularVelocity, omega * .999f, omega * 1.001f);
Assert.InRange(d.TrackingRate, rate * .999f, rate * 1.001f);
Assert.Equal((float) Math.Pow(.5, Math.Pow(omega / rate, 2)), d.PMount, 4);
var price = FireControl.HitProbability(weapon, shooter, range.Target);
var withoutMount = d.PFire * d.PSpread * d.POnHull * d.PEvasion;
Assert.True(withoutMount > 0f, "fixture: the shot is priced without the mount");
Assert.InRange(price, withoutMount * d.PMount * .999f, withoutMount * d.PMount * 1.001f);
Assert.Equal(price, d.PBase, 6);
mounts.Add(d.PMount);
}
Assert.True(mounts[0] >= .85f, $"the light gun follows a fast close crosser: {mounts[0]}");
Assert.True(mounts[1] <= .01f, $"the heavy gun does not: {mounts[1]}");
}
// Ruling jink-per-ship: the changes-of-vector term is forgiven by the ship's gear alone, in metres, whatever the gun.
// The target flies straight along the line of sight after jinking (no sweep, PMount 1), so the evasion is all there is.
[Fact]
public void TheJinkIsStillForgivenPerShip()
{
var range = EvMountRange(40f, 3f);
EvCross(range, jinking: true, float2(0, -50));
foreach (var (weapon, shooter) in EvGuns(range))
{
var d = FireControl.Inspect(weapon, shooter, range.Target);
Assert.Equal(1f, d.PMount, 4);
Assert.True(d.Evasion > 0f, "fixture: the target jinks");
Assert.Equal(FireControl.PDeviation(d.Evasion, FireControl.Tracking(shooter)), d.PEvasion, 5);
var id = FireControl.Fire(weapon, weapon.Item, shooter);
Assert.True(id > 0, "fixture: the weapon fires");
var shot = range.Arena.PendingShots.Single(s => s.ShotId == id);
Assert.Equal(FireControl.Tracking(shooter), shot.Tracking);
}
}
// Finding jink-price-unpinned, ruling jink-per-ship at the price layer: with the target flying straight at the shooter (no
// sweep, PMount 1) the live price of two guns of different rates carries the same jink factor, PDeviation(Evasion,
// Tracking(shooter)), whatever their rates. The prices differ by the guns' own spread and fire factors; the jink does not.
[Fact]
public void TheJinkIsForgivenPerShipInThePrice()
{
var range = EvMountRange(40f, 3f);
EvCross(range, jinking: true, float2(0, -50));
var guns = EvGuns(range);
Assert.NotEqual(guns[0].weapon.Tracking, guns[1].weapon.Tracking);
var factors = new List<float>();
foreach (var (weapon, shooter) in guns)
{
var d = FireControl.Inspect(weapon, shooter, range.Target);
Assert.Equal(1f, d.PMount, 4);
var forgiven = FireControl.PDeviation(d.Evasion, FireControl.Tracking(shooter));
Assert.True(forgiven > .01f && forgiven < .99f, $"fixture: the jink prices between nothing and everything: {forgiven}");
var shared = d.PFire * d.PMount * d.PSpread * d.POnHull;
Assert.True(shared > 0f, "fixture: the shared factors price the shot");
var price = FireControl.HitProbability(weapon, shooter, range.Target);
Assert.InRange(price, shared * forgiven * .999f, shared * forgiven * 1.001f);
factors.Add(price / shared);
}
Assert.InRange(factors[0], factors[1] * .999f, factors[1] * 1.001f);
}
// Finding zero-gear-nan: a gun with no authored limit follows anything, whatever the gear reads. Infinity times a gear
// that reads zero (active, not yet executed) is still infinity, so the price stays a number; a limited gun on that gear
// follows nothing that moves (rate 0). Both keep the ship's gear as the other factor of the product (tracking-per-gun).
[Fact]
public void AnUnlimitedGunFollowsAnythingOnGearThatReadsZero()
{
var range = EvMountRange(float.PositiveInfinity, 3f);
EvCross(range, jinking: false, float2(100, 0));
var gear = range.Gunner.GetBehaviors<TargetingSystem>().First(t => t.Item.Active.Value);
Assert.True(FireControl.Tracking(range.Gunner) > 0f, "fixture: the gear reads a positive Tracking before it is zeroed");
((TargetingSystemData) gear.Data).Tracking = EvRate(0f);
range.Gunner.Update(EvDt);
range.Gunner.Update(EvDt);
Assert.Equal(0f, FireControl.Tracking(range.Gunner));
Assert.True(float.IsPositiveInfinity(FireControl.TrackingRate(range.Gun, range.Gunner)), "an unlimited gun's rate is +infinity on any gear");
Assert.Equal(1f, FireControl.Inspect(range.Gun, range.Gunner, range.Target).PMount);
Assert.False(float.IsNaN(FireControl.HitProbability(range.Gun, range.Gunner, range.Target)));
Assert.Equal(0f, FireControl.TrackingRate(range.GunnerLaser, range.Gunner));
Assert.Equal(0f, FireControl.Inspect(range.GunnerLaser, range.Gunner, range.Target).PMount);
}
// The mount factor is frozen at fire inside PFire, and the commit price is the live price.
[Fact]
public void AShotFreezesItsMount()
{
var range = EvMountRange(40f, 3f);
EvCross(range, jinking: false, float2(100, 0));
foreach (var (weapon, shooter) in EvGuns(range))
{
var d = FireControl.Inspect(weapon, shooter, range.Target);
var live = FireControl.HitProbability(weapon, shooter, range.Target);
Assert.True(d.PMount < 1f && d.PMount > 0f && d.PFire > 0f, "fixture: the mount prices the shot");
var id = FireControl.Fire(weapon, weapon.Item, shooter);
Assert.True(id > 0, "fixture: the weapon fires");
var shot = range.Arena.PendingShots.Single(s => s.ShotId == id);
Assert.InRange(shot.PFire, d.PFire * d.PMount * .999f, d.PFire * d.PMount * 1.001f);
var commit = FireControl.CommitProbability(shot, range.Arena.Time, out _);
Assert.InRange(commit, live * .999f, live * 1.001f);
// The target turns to fly straight along the line of sight: the live mount factor is now 1, the frozen one is not.
range.Target.Velocity = float2(0, 100);
Assert.Equal(1f, FireControl.Inspect(weapon, shooter, range.Target).PMount, 4);
shot = range.Arena.PendingShots.Single(s => s.ShotId == id);
Assert.InRange(shot.PFire, d.PFire * d.PMount * .999f, d.PFire * d.PMount * 1.001f);
Assert.InRange(FireControl.CommitProbability(shot, range.Arena.Time, out _), commit * .999f, commit * 1.001f);
range.Target.Velocity = float2(100, 0);
}
// A round fired at nothing carries no mount factor to freeze: it prices at nothing and nothing throws.
range.Gunner.SetTarget(TargetRef.None);
var untargeted = FireControl.Fire(range.Gun, range.Gun.Item, range.Gunner);
Assert.True(untargeted > 0, "fixture: the weapon fires at nothing");
Assert.Equal(0f, range.Arena.PendingShots.Single(s => s.ShotId == untargeted).PFire);
}
// A gun nobody has authored a rate for follows anything: +infinity, and PMount 1 exactly.
[Fact]
public void AnUnauthoredWeaponHasNoMountLimit()
{
var unauthored = new AutoWeaponData().Tracking;
Assert.True(float.IsPositiveInfinity(unauthored.Min) && float.IsPositiveInfinity(unauthored.Max), "a code-built weapon's rate is +infinity");
var range = EvMountRange(5f, 5f);
EvCross(range, jinking: false, float2(100, 0));
var limited = FireControl.HitProbability(range.Gun, range.Gunner, range.Target);
range.Gun.WeaponData.Tracking = unauthored;
range.Gunner.Update(EvDt);
range.Gunner.Update(EvDt);
Assert.True(float.IsPositiveInfinity(range.Gun.Tracking), $"it evaluates to +infinity: {range.Gun.Tracking}");
Assert.True(float.IsPositiveInfinity(FireControl.TrackingRate(range.Gun, range.Gunner)));
var d = FireControl.Inspect(range.Gun, range.Gunner, range.Target);
Assert.Equal(1f, d.PMount);
var price = FireControl.HitProbability(range.Gun, range.Gunner, range.Target);
var withoutMount = d.PFire * d.PSpread * d.POnHull * d.PEvasion;
Assert.InRange(price, withoutMount * .999f, withoutMount * 1.001f);
Assert.True(price > limited * 2f, $"the mount limit priced the limited gun down: {limited} against {price}");
}
// The shipped catalog's weapon behaviours: launchers and guided weapons never mount-track, every other gun is a mount.
private static bool EvMountless(WeaponData data) => data is LauncherData || data is GuidedWeaponData;
private IEnumerable<(WeaponItemData item, WeaponData data)> EvCatalogGuns() =>
_cache.GetAll<WeaponItemData>().SelectMany(item => item.Behaviors.OfType<WeaponData>().Select(data => (item, data)));
// Data check, not a behaviour test: it reads the migrated catalog. Pins finiteness and the authored shape of the
// value (Min = Max > 0), never a value (ruling weapon-tracking-authored).
[Fact]
public void EveryCatalogGunTracks()
{
var guns = EvCatalogGuns().ToArray();
Assert.NotEmpty(guns);
foreach (var (item, data) in guns)
{
var rate = data.Tracking;
if (EvMountless(data))
Assert.True(float.IsPositiveInfinity(rate.Min) && float.IsPositiveInfinity(rate.Max), $"{item.Name} is a launcher or guided weapon and tracks without a mount limit");
else
{
Assert.True(!float.IsInfinity(rate.Min) && !float.IsNaN(rate.Min), $"{item.Name} has a finite rate, not {rate.Min}");
Assert.Equal(rate.Min, rate.Max);
Assert.True(rate.Min > 0f, $"{item.Name} has a positive rate, not {rate.Min}");
}
}
}
// Data check, not a behaviour test: ruling plight-shape, plight is one wide, two high and fits a Longinus Energy hardpoint.
[Fact]
public void PlightHasItsRuledShape()
{
var plight = _cache.GetAll<WeaponItemData>().Single(w => w.Name == "plight");
Assert.Equal(1, plight.Shape.Width);
Assert.Equal(2, plight.Shape.Height);
Assert.True(plight.Shape.Cells[0, 0] && plight.Shape.Cells[0, 1], "both cells are occupied");
var longinus = _cache.GetAll<HullData>().Single(h => h.Name == "Longinus");
Assert.Contains(longinus.Hardpoints, hardpoint => hardpoint.Type == HardpointType.Energy && hardpoint.Takes(plight));
}
}