Aetheria/tests/Aetheria.Shared.Tests/BallisticFlightTests.cs
Meta Vens 6b36835953 Ballistic flight r3: name CultMath.float2x2.Rotate in full (the math.float2x2 method shadows the type)
The previous commit (2dcb7b20) does not build: DrawAhead.cs CS0119.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:29:32 +02:00

534 lines
28 KiB
C#

/* 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.Linq;
using System.Reflection;
using UniRx;
using Xunit;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
using int2 = CultMath.int2;
// Ballistic flight: a round's flight is presentation (ruling projectile-flight-is-presentation). The simulation publishes
// the facts (the PendingShot's frozen origin, direction, speed, fire time and arrival, the outcome with its commit tick,
// impact cell and burst point) and DrawAhead's statics draw the round from them: Round the one statement of where a round
// is at a sim time, Impact where a hit lands on the target's drawn pose, RoundEnd and RoundOver when its drawn flight
// ends. FireControl owns none of it (ruling direct-arrival-keep-timing: damage keeps landing at the arrival time, the
// hit is drawn at its cell). Unity's Projectile calls Round and integrates nothing, so these tests pin the drawing
// against first principles (the shooter's position, the aim, the authored velocity and range, the hull's own frame)
// rather than against the code that computes it. Shares the Cut 12.4 engagement fixture.
public sealed partial class FireControlCut124Tests
{
private const float BallisticSpeed = 20f;
// A no-lock proximity round: flies the aim line (2,1) to max range, bursting there.
private Engagement FireBurstRound(out float3 origin)
{
var e = Build(TestSettings(), SolidShape(5, 4), velocity: BallisticSpeed, fuse: WeaponFuse.Proximity, blastRadius: 4f,
damage: 100f, weaponRange: NoLockRange);
Aim(e, float2(2, 1));
e.Shooter.SetTarget(TargetRef.None);
origin = e.Shooter.Position;
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
return e;
}
// What the sim publishes for a shot, as a presenter holds it: only the result (no impact, no burst point).
private static ShotOutcome Known(in PendingShot shot, ShotResult result) =>
new ShotOutcome { ShotId = shot.ShotId, Result = result, ArrivalIn = max(0f, shot.ArrivalTime - shot.CommitTime) };
// A hit on a cell of the target, committed at `commit`: ArrivalIn is how long after the commit damage lands.
private static ShotOutcome Hit(in PendingShot shot, Entity target, int2 cell, float commit) =>
new ShotOutcome { ShotId = shot.ShotId, Result = ShotResult.Hit, Target = target, Cell = cell, ArrivalIn = shot.ArrivalTime - commit };
// The hull's occupied coordinate farthest from its centre of mass: a cell well off the target's position.
private static int2 FarCell(Engagement e) =>
e.HullData.Shape.Coordinates.OrderByDescending(c => length((float2) c - e.HullData.Shape.CenterOfMass)).First();
[Fact]
public void ARoundIsDrawnOnItsFiredLine()
{
// Burst round: leaves the shooter, is where its own speed puts it a second in, and ends on the burst point.
var e = FireBurstRound(out var origin);
ShotOutcome outcome = null;
PendingShot burst = default;
using var c = e.Zone.ShotCommitted.Subscribe(o =>
{
outcome = o;
e.Zone.TryGetShot(o.ShotId, out burst);
});
e.Zone.Update(.01f);
Assert.NotNull(outcome);
Assert.True(outcome.HasBurstPoint);
var aim = normalize(float2(2, 1));
// The shooter moves on after the round left: the line is the one frozen at Fire, not one drawn from where it is now.
e.Shooter.Position += float3(7, 0, 3);
var start = DrawAhead.Round(burst, null, burst.FireOrigin, 0f, burst.FireTime, 0f);
Assert.Equal(origin.x, start.x, 3);
Assert.Equal(origin.z, start.z, 3);
var second = DrawAhead.Round(burst, null, burst.FireOrigin, 0f, burst.FireTime + 1f, 0f);
Assert.Equal(origin.x + aim.x * BallisticSpeed, second.x, 3);
Assert.Equal(origin.z + aim.y * BallisticSpeed, second.z, 3);
foreach (var known in new[] { null, outcome })
foreach (var later in new[] { 0f, 1f, 50f })
{
var arrival = DrawAhead.Round(burst, known, burst.FireOrigin, 0f, burst.ArrivalTime + later, 0f);
Assert.Equal(burst.BurstPosition.x, arrival.x, 3);
Assert.Equal(burst.BurstPosition.z, arrival.z, 3);
}
// Past the weapon's range it is held at the range point, however late it is asked.
var held = DrawAhead.Round(burst, Known(burst, ShotResult.Miss), burst.FireOrigin, 0f, burst.FireTime + NoLockRange / BallisticSpeed + 50f, 0f);
Assert.Equal(origin.x + aim.x * NoLockRange, held.x, 3);
Assert.Equal(origin.z + aim.y * NoLockRange, held.z, 3);
// Before it was fired it is still at the origin, not behind it.
var before = DrawAhead.Round(burst, null, burst.FireOrigin, 0f, burst.FireTime - 5f, 0f);
Assert.Equal(origin.x, before.x, 3);
Assert.Equal(origin.z, before.z, 3);
// Direct round at a stationary target 100 ahead (+z): arrives on the target, and a miss would fly on to the weapon's range.
var d = Build(TestSettings(), SolidShape(5, 4), velocity: BallisticSpeed, weaponRange: 1000f);
var dOrigin = d.Shooter.Position;
FireControl.Fire(d.Weapon, d.WeaponItem, d.Shooter);
var direct = SafeAssert.OnlyShot(d.Zone);
// A round not known to miss waits at its arrival point however late it is asked.
foreach (var at in new[] { direct.ArrivalTime, direct.FireTime + 99f })
{
var waiting = DrawAhead.Round(direct, null, direct.FireOrigin, 0f, at, 0f);
Assert.Equal(d.Target.Position.x, waiting.x, 2);
Assert.Equal(d.Target.Position.z, waiting.z, 2);
}
Assert.Equal(dOrigin.z + 100f / 2f, DrawAhead.Round(direct, null, direct.FireOrigin, 0f, direct.FireTime + 2.5f, 0f).z, 2);
var heldDirect = DrawAhead.Round(direct, Known(direct, ShotResult.Miss), direct.FireOrigin, 0f, direct.FireTime + 1000f / BallisticSpeed + 99f, 0f);
Assert.Equal(dOrigin.z + 1000f, heldDirect.z, 2);
}
// A round with no speed (the weapon authors Velocity 0, or effectively 0) does not fly: it is at its origin at any
// time, and is not stretched along its line by a vanishing speed over the range it would need forever to cover.
[Fact]
public void ARoundWithNoSpeedStaysAtItsOrigin()
{
var e = Build(TestSettings(), SolidShape(5, 4), velocity: .005f, weaponRange: 1000f);
var origin = e.Shooter.Position;
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
var shot = SafeAssert.OnlyShot(e.Zone);
var late = DrawAhead.Round(shot, Known(shot, ShotResult.Miss), shot.FireOrigin, 0f, shot.FireTime + 5000f, 0f);
Assert.Equal(origin.x, late.x, 3);
Assert.Equal(origin.z, late.z, 3);
}
[Fact]
public void SpeedIsFrozenAtFire()
{
var e = FireBurstRound(out var origin);
var shot = SafeAssert.OnlyShot(e.Zone);
Assert.Equal(BallisticSpeed, shot.Speed, 4);
// The stat changes after the round left, and the weapon re-reads it.
((InstantWeaponData) e.Weapon.WeaponData).Velocity = Constant(BallisticSpeed * 5f);
e.Weapon.Execute(0f);
Assert.Equal(BallisticSpeed * 5f, e.Weapon.Velocity, 4);
var after = SafeAssert.OnlyShot(e.Zone);
Assert.Equal(BallisticSpeed, after.Speed, 4);
var aim = normalize(float2(2, 1));
var at = DrawAhead.Round(after, null, after.FireOrigin, 0f, after.FireTime + 1f, 0f);
Assert.Equal(origin.x + aim.x * BallisticSpeed, at.x, 3);
Assert.Equal(origin.z + aim.y * BallisticSpeed, at.z, 3);
}
[Fact]
public void ARoundIsPure()
{
var e = FireDirectRound(20f, 1000f);
var shot = SafeAssert.OnlyShot(e.Zone);
e.Target.Direction = normalize(float2(1, 2));
e.Target.TurnRate = 3f;
var time = e.Zone.Time;
var entities = e.Zone.Entities.ToList();
var positions = entities.Select(x => x.Position).ToList();
var velocities = entities.Select(x => x.Velocity).ToList();
var directions = entities.Select(x => x.Direction).ToList();
var turnRates = entities.Select(x => x.TurnRate).ToList();
var barrel = shot.FireOrigin + float3(1, .5f, -2);
var cell = FarCell(e);
foreach (var known in new[] { null, Known(shot, ShotResult.Miss), Hit(shot, e.Target, cell, shot.CommitTime) })
for (var i = 0; i < 25; i++)
{
var at = shot.FireTime + i * .37f;
DrawAhead.Round(shot, known, barrel, .1f, at, .01f);
DrawAhead.RoundOver(shot, known, at, .01f);
DrawAhead.RoundEnd(shot, known?.Result);
DrawAhead.Impact(e.Target, cell, .01f * i);
}
Assert.Equal(time, e.Zone.Time);
Assert.True(e.Zone.PendingShots.Count == 1 && e.Zone.PendingShots[0].Equals(shot), "the pending shot changed");
Assert.True(e.Zone.Entities.SequenceEqual(entities), "the zone's entities changed");
Assert.True(entities.Select(x => x.Position).SequenceEqual(positions), "an entity moved");
Assert.True(entities.Select(x => x.Velocity).SequenceEqual(velocities), "an entity's velocity changed");
Assert.True(entities.Select(x => x.Direction).SequenceEqual(directions), "an entity turned");
Assert.True(entities.Select(x => x.TurnRate).SequenceEqual(turnRates), "an entity's turn rate changed");
}
// The weapon's Range is frozen with the rest: a round that misses flies on to the range it left with, whatever the stat
// becomes after.
[Fact]
public void RangeIsFrozenAtFire()
{
var e = FireBurstRound(out var origin);
var shot = SafeAssert.OnlyShot(e.Zone);
Assert.Equal(NoLockRange, shot.MaxRange, 4);
var endBefore = DrawAhead.RoundEnd(shot, ShotResult.Miss);
// The stat changes after the round left, and the weapon re-reads it.
((InstantWeaponData) e.Weapon.WeaponData).Range = Constant(NoLockRange * 5f);
e.Weapon.Execute(0f);
Assert.Equal(NoLockRange * 5f, e.Weapon.Range, 4);
var after = SafeAssert.OnlyShot(e.Zone);
Assert.Equal(NoLockRange, after.MaxRange, 4);
Assert.Equal(endBefore, DrawAhead.RoundEnd(after, ShotResult.Miss), 4);
var aim = normalize(float2(2, 1));
var held = DrawAhead.Round(after, Known(after, ShotResult.Miss), after.FireOrigin, 0f, after.FireTime + 1000f, 0f);
Assert.Equal(origin.x + aim.x * NoLockRange, held.x, 3);
Assert.Equal(origin.z + aim.y * NoLockRange, held.z, 3);
}
// A direct round at a stationary target `targetRange` ahead (+z) of the shooter, weapon Range `range`, at `speed`;
// or, with atNothing, the same weapon fired along +z at no target.
private Engagement FireDirectRound(float speed, float range, float targetRange = 100f, bool atNothing = false)
{
var e = Build(TestSettings(), SolidShape(5, 4), velocity: speed, weaponRange: range, targetRange: targetRange);
if (atNothing)
{
Aim(e, float2(0, 1));
e.Shooter.SetTarget(TargetRef.None);
}
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
return e;
}
// A hit, a burst or an outcome not yet known ends at the round's arrival; a miss flies on to the weapon's range and
// never ends short of its own arrival; only a published miss is over by the clock.
[Fact]
public void RoundEndAndRoundOverAreTheOutcomesEnd()
{
foreach (var speed in new[] { 20f, 200f })
{
var d = FireDirectRound(speed, 1000f);
var direct = SafeAssert.OnlyShot(d.Zone);
Assert.Equal(direct.FireTime + 100f / speed, direct.ArrivalTime, 4);
Assert.Equal(direct.FireTime + 100f / speed, DrawAhead.RoundEnd(direct, null), 4);
Assert.Equal(direct.FireTime + 100f / speed, DrawAhead.RoundEnd(direct, ShotResult.Hit), 4);
Assert.Equal(direct.FireTime + 100f / speed, DrawAhead.RoundEnd(direct, ShotResult.Burst), 4);
Assert.Equal(direct.FireTime + 1000f / speed, DrawAhead.RoundEnd(direct, ShotResult.Miss), 4);
Assert.False(DrawAhead.RoundOver(direct, null, direct.FireTime + 10000f, 0f));
foreach (var result in new[] { ShotResult.Hit, ShotResult.Burst })
Assert.False(DrawAhead.RoundOver(direct, Known(direct, result), direct.FireTime + 10000f, 0f));
var missEnd = direct.FireTime + 1000f / speed;
Assert.False(DrawAhead.RoundOver(direct, Known(direct, ShotResult.Miss), missEnd - .01f, 0f));
Assert.True(DrawAhead.RoundOver(direct, Known(direct, ShotResult.Miss), missEnd, 0f));
// A target beyond the weapon's range: a miss never ends short of its own arrival.
var far = FireDirectRound(speed, 100f, targetRange: 150f);
var beyond = SafeAssert.OnlyShot(far.Zone);
Assert.Equal(beyond.FireTime + 150f / speed, beyond.ArrivalTime, 4);
Assert.Equal(beyond.FireTime + 150f / speed, DrawAhead.RoundEnd(beyond, ShotResult.Miss), 4);
// A round fired at nothing arrives where it left, and a miss flies on to the range.
var none = FireDirectRound(speed, 100f, atNothing: true);
var nothing = SafeAssert.OnlyShot(none.Zone);
Assert.Equal(nothing.FireTime, nothing.ArrivalTime, 4);
Assert.Equal(nothing.FireTime, DrawAhead.RoundEnd(nothing, null), 4);
Assert.Equal(nothing.FireTime + 100f / speed, DrawAhead.RoundEnd(nothing, ShotResult.Miss), 4);
}
}
// The frame falls Lead (< one step) after the last step, so a hit or burst is drawn at Zone.Time + Lead; it must stay
// on the arrival point until the sim resolves it, and only a published miss flies on.
[Theory]
[InlineData(20f)]
[InlineData(200f)]
public void ARoundIsDrawnNoFurtherThanItsArrivalUntilItMisses(float speed)
{
var e = FireDirectRound(speed, 1000f);
var shot = SafeAssert.OnlyShot(e.Zone);
var origin = shot.FireOrigin;
const float step = 1f / 30f;
foreach (var lead in new[] { 0f, step / 2f, .999f * step })
{
var waiting = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, shot.ArrivalTime, lead);
Assert.Equal(origin.x, waiting.x, 3);
Assert.Equal(origin.z + 100f, waiting.z, 3);
var missed = DrawAhead.Round(shot, Known(shot, ShotResult.Miss), shot.FireOrigin, 0f, shot.ArrivalTime, lead);
Assert.Equal(origin.x, missed.x, 3);
Assert.Equal(origin.z + 100f + lead * speed, missed.z, 3);
}
for (var time = shot.FireTime; time <= shot.ArrivalTime + 1f; time += 1f / 144f)
{
var drawn = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, time, 0f);
Assert.True(drawn.z - origin.z <= 100f + 1e-3f, "the round was drawn past its arrival point");
}
}
// A no-lock contact round stopped by a hull short of max range has its arrival shortened at the commit; the
// simulation writes the record back before ShotCommitted fires, so a presenter re-reading it there draws the stop
// at the committed arrival, not the fire-time copy's.
[Fact]
public void TheCommittedRecordCarriesTheCommittedArrival()
{
var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f,
weaponRange: NoLockRange);
e.Shooter.SetTarget(TargetRef.None);
Aim(e, float2(0, 1));
AddShip(e, PointAlong(e, float2(0, 1), 25f), 1f, 300);
ShotOutcome outcome = null;
PendingShot record = default;
var found = false;
using var c = e.Zone.ShotCommitted.Subscribe(o =>
{
outcome = o;
found = e.Zone.TryGetShot(o.ShotId, out record);
});
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
var fired = SafeAssert.OnlyShot(e.Zone);
Assert.False(fired.Committed);
e.Zone.Update(.01f);
Assert.True(found, "the committed record was not readable when ShotCommitted fired");
Assert.True(record.Committed);
Assert.Equal(fired.FireTime + 3f, fired.ArrivalTime, 2);
Assert.Equal(fired.FireTime + 1.05f, record.ArrivalTime, 2);
Assert.True(record.ArrivalTime < fired.ArrivalTime);
var stop = DrawAhead.Round(record, outcome, record.FireOrigin, 0f, record.ArrivalTime, 0f);
var expected = outcome.HasBurstPoint
? outcome.BurstPoint
: record.FireOrigin.xz + record.TravelDirection * (20f * (record.ArrivalTime - record.FireTime));
Assert.Equal(expected.x, stop.x, 3);
Assert.Equal(expected.y, stop.z, 3);
foreach (var later in new[] { 0f, .5f, 5f })
{
var drawn = DrawAhead.Round(record, outcome, record.FireOrigin, 0f, record.ArrivalTime + later, 0f);
Assert.Equal(expected.x, drawn.x, 3);
Assert.Equal(expected.y, drawn.z, 3);
}
}
// The barrel is only where the round is first seen: its offset from the sim line decays linearly to nothing over
// blendTime sim seconds from FireTime.
[Theory]
[InlineData(.1f)]
[InlineData(.25f)]
public void TheBarrelBlendDecaysOnSimTime(float blend)
{
var e = FireBurstRound(out var origin);
var shot = SafeAssert.OnlyShot(e.Zone);
var aim = normalize(float2(2, 1));
var offset = float3(1, .5f, -2);
var barrel = shot.FireOrigin + offset;
float3 Line(float dt) => float3(origin.x + aim.x * BallisticSpeed * dt, origin.y, origin.z + aim.y * BallisticSpeed * dt);
void Close(float3 want, float3 got)
{
Assert.Equal(want.x, got.x, 3);
Assert.Equal(want.y, got.y, 3);
Assert.Equal(want.z, got.z, 3);
}
Close(barrel, DrawAhead.Round(shot, null, barrel, blend, shot.FireTime, 0f));
Close(Line(blend / 2f) + offset * .5f, DrawAhead.Round(shot, null, barrel, blend, shot.FireTime + blend / 2f, 0f));
Close(Line(blend), DrawAhead.Round(shot, null, barrel, blend, shot.FireTime + blend, 0f));
Close(Line(2f * blend), DrawAhead.Round(shot, null, barrel, blend, shot.FireTime + 2f * blend, 0f));
Close(Line(0f), DrawAhead.Round(shot, null, barrel, 0f, shot.FireTime, 0f));
}
// Placement depends on sim time alone: any history of frames walked to a checkpoint draws what a fresh call draws, which
// is the frozen line at the frozen speed.
[Theory]
[InlineData(20f)]
[InlineData(200f)]
public void ARoundIsAFunctionOfSimTime(float speed)
{
var e = FireDirectRound(speed, 1000f);
var shot = SafeAssert.OnlyShot(e.Zone);
var checkpoints = new[] { .05f, .3f, 1f, 3f };
var histories = new[] { new[] { 1f / 144f }, new[] { 1f / 30f }, new[] { 1f / 144f, 1f / 30f, 1f / 90f } };
var miss = Known(shot, ShotResult.Miss);
foreach (var frames in histories)
{
var elapsed = 0f;
var frame = 0;
foreach (var checkpoint in checkpoints)
{
while (elapsed + 1e-4f < checkpoint)
{
elapsed = min(checkpoint, elapsed + frames[frame++ % frames.Length]);
DrawAhead.Round(shot, miss, shot.FireOrigin, .1f, shot.FireTime + elapsed, 0f);
}
var drawn = DrawAhead.Round(shot, miss, shot.FireOrigin, .1f, shot.FireTime + checkpoint, 0f);
var fresh = DrawAhead.Round(shot, miss, shot.FireOrigin, .1f, shot.FireTime + checkpoint, 0f);
// A frame that falls `lead` after the step draws what the sim time lead later would.
foreach (var lead in new[] { 1f / 60f, 1f / 90f })
{
var ahead = DrawAhead.Round(shot, miss, shot.FireOrigin, .1f, shot.FireTime + checkpoint - lead, lead);
Assert.Equal(fresh.x, ahead.x, 3);
Assert.Equal(fresh.y, ahead.y, 3);
Assert.Equal(fresh.z, ahead.z, 3);
}
Assert.Equal(fresh.z, drawn.z, 3);
Assert.Equal(shot.FireOrigin.z + speed * min(checkpoint, 1000f / speed), drawn.z, 3);
Assert.Equal(shot.FireOrigin.x, drawn.x, 3);
Assert.Equal(shot.FireOrigin.y, drawn.y, 3);
}
}
}
// A hit ends at its impact cell on the target's drawn pose when damage lands, not at the arrival point of the line
// frozen at Fire (ruling direct-arrival-keep-timing): the target has moved and turned since, and is drawn ahead by its
// last step's velocity and turn. The expected point is computed apart from DrawAhead, by moving the entity itself and
// asking its own schematic frame (Entity.ToWorldPoint) where the cell is.
[Theory]
[InlineData(20f)]
[InlineData(200f)]
public void AHitEndsAtItsImpactCellOnTheTargetsDrawnPose(float speed)
{
var e = Build(TestSettings(), SolidShape(5, 4), velocity: speed, weaponRange: 1000f);
e.Target.Velocity = float2(10, 0);
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
var shot = SafeAssert.OnlyShot(e.Zone);
Assert.True(shot.TravelDirection.x > 0f, "the round did not lead the target");
const float step = 1f / 30f;
e.Target.Position += float3(40, 0, 0);
e.Target.Direction = normalize(float2(1, 2));
e.Target.TurnRate = 3f;
var cell = FarCell(e);
var hit = Hit(shot, e.Target, cell, shot.CommitTime);
var arrivalOnTheLine = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, shot.ArrivalTime, 0f);
var cellSize = e.Items.GameplaySettings.SchematicCellSize;
foreach (var lead in new[] { 0f, step / 2f, .999f * step })
{
var position = e.Target.Position;
var direction = e.Target.Direction;
e.Target.Position += float3(e.Target.Velocity.x, 0, e.Target.Velocity.y) * lead;
e.Target.Direction = mul(e.Target.Direction, CultMath.float2x2.Rotate(e.Target.TurnRate * lead));
var cellPoint = e.Target.ToWorldPoint((float2) cell);
var expected = float3(cellPoint.x, e.Target.Position.y, cellPoint.y);
e.Target.Position = position;
e.Target.Direction = direction;
Assert.True(length((expected - arrivalOnTheLine).xz) > cellSize, "the fixture put the cell on the frozen line's arrival point");
var impact = DrawAhead.Impact(e.Target, cell, lead);
Assert.Equal(expected.x, impact.x, 3);
Assert.Equal(expected.y, impact.y, 3);
Assert.Equal(expected.z, impact.z, 3);
foreach (var simTime in new[] { shot.ArrivalTime, shot.ArrivalTime + .5f })
{
var drawn = DrawAhead.Round(shot, hit, shot.FireOrigin, .1f, simTime, lead);
Assert.Equal(expected.x, drawn.x, 3);
Assert.Equal(expected.y, drawn.y, 3);
Assert.Equal(expected.z, drawn.z, 3);
}
}
}
// A hit leaves its frozen line at the commit tick and steers onto its end by the time damage lands: before the commit it
// is on the line, half way through the steer it is half way to the impact cell, and a frame drawn ahead by the clock's
// lead places the round where the sim time that much later would.
[Fact]
public void ASteeredRoundLeavesItsLineAtTheCommit()
{
var e = FireDirectRound(20f, 1000f);
var shot = SafeAssert.OnlyShot(e.Zone);
Assert.Equal(shot.FireTime + 5f, shot.ArrivalTime, 3);
var cell = FarCell(e);
var commit = shot.ArrivalTime - 2f;
var hit = Hit(shot, e.Target, cell, commit);
foreach (var t in new[] { commit - 1f, commit })
{
var steered = DrawAhead.Round(shot, hit, shot.FireOrigin, 0f, t, 0f);
var line = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, t, 0f);
Assert.Equal(line.x, steered.x, 3);
Assert.Equal(line.y, steered.y, 3);
Assert.Equal(line.z, steered.z, 3);
}
var m = commit + 1f;
var onTheLine = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, m, 0f);
var end = DrawAhead.Impact(e.Target, cell, 0f);
Assert.True(length((end - onTheLine).xz) > 1f, "the fixture put the impact cell on the line");
var halfway = DrawAhead.Round(shot, hit, shot.FireOrigin, 0f, m, 0f);
Assert.Equal(onTheLine.x + (end.x - onTheLine.x) * .5f, halfway.x, 3);
Assert.Equal(onTheLine.y + (end.y - onTheLine.y) * .5f, halfway.y, 3);
Assert.Equal(onTheLine.z + (end.z - onTheLine.z) * .5f, halfway.z, 3);
var ahead = DrawAhead.Round(shot, hit, shot.FireOrigin, 0f, m - .02f, .02f);
Assert.Equal(halfway.x, ahead.x, 3);
Assert.Equal(halfway.y, ahead.y, 3);
Assert.Equal(halfway.z, ahead.z, 3);
}
// The sim states facts and draws nothing: no drawing function, and no lead, blend or barrel, lives in FireControl;
// those belong to DrawAhead, the presentation statics.
[Fact]
public void FireControlHasNoDrawingFunctions()
{
const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance | BindingFlags.DeclaredOnly;
var methods = typeof(FireControl).GetMethods(all).Cast<MethodBase>().Concat(typeof(FireControl).GetConstructors(all)).ToList();
Assert.NotEmpty(methods);
foreach (var method in methods)
{
Assert.False(method.Name.StartsWith("Round") || method.Name.StartsWith("Draw"), "FireControl has a drawing function: " + method.Name);
foreach (var parameter in method.GetParameters())
Assert.False(new[] { "lead", "blendTime", "barrel" }.Contains(parameter.Name), "FireControl takes a drawing parameter: " + parameter.Name);
}
foreach (var name in new[] { "Round", "Impact", "RoundOver", "RoundEnd" })
Assert.NotNull(typeof(DrawAhead).GetMethod(name, BindingFlags.Public | BindingFlags.Static));
}
// A proximity round at a target that recedes faster than the round closes bursts at the weapon's range, pulled back
// along its line, while it arrives when it has flown the distance the target was fired at: its burst point is not
// the line at its arrival. The end is the sim's burst point, so a presenter that ended at the arrival line would
// end the tracer short of where the round burst.
[Fact]
public void ABurstRoundEndsAtItsBurstPointNotItsArrivalLine()
{
var e = Build(TestSettings(), SolidShape(5, 4), velocity: BallisticSpeed, fuse: WeaponFuse.Proximity, blastRadius: 4f,
damage: 100f, weaponRange: NoLockRange, targetRange: 50f);
e.Target.Velocity = float2(0, 15);
ShotOutcome outcome = null;
using var c = e.Zone.ShotCommitted.Subscribe(o => outcome = o);
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
var origin = SafeAssert.OnlyShot(e.Zone).FireOrigin;
for (var i = 0; i < 100 && outcome == null; i++) e.Zone.Update(.05f);
Assert.NotNull(outcome);
Assert.True(outcome.HasBurstPoint);
Assert.True(e.Zone.TryGetShot(outcome.ShotId, out var record));
Assert.Equal(origin.x, outcome.BurstPoint.x, 3);
Assert.Equal(origin.z + NoLockRange, outcome.BurstPoint.y, 3);
Assert.Equal(origin.z + 50f, DrawAhead.Round(record, null, record.FireOrigin, 0f, record.ArrivalTime, 0f).z, 3);
var stop = DrawAhead.Round(record, outcome, record.FireOrigin, 0f, record.ArrivalTime, 0f);
Assert.Equal(outcome.BurstPoint.x, stop.x, 3);
Assert.Equal(outcome.BurstPoint.y, stop.z, 3);
}
}