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:
commit
179ab92abc
11 changed files with 1653 additions and 20 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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> --
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
BIN
GameData/Aetheria.cc
(Stored with Git LFS)
Binary file not shown.
1026
tests/Aetheria.Shared.Tests/EvasionTermTests.cs
Normal file
1026
tests/Aetheria.Shared.Tests/EvasionTermTests.cs
Normal file
File diff suppressed because it is too large
Load diff
326
tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs
Normal file
326
tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue