Fire on solutions: FireControl.Solution is each gun's one solution; the trigger fires on it without the aim

Bears is extracted from PFire; ArcPermitsFire passes on a fused gun, a solution, or a free aim in arc.
Solve flies along Solution's direction. ArticulationPoint loses its yaw clamp and follows a per-group
aim point placed from the group's solution. Arcs gets its new brief; Broadside is new.
Unverified: first commit of the cut, not yet built.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-08 13:25:53 +02:00
commit e103b90aea
13 changed files with 370 additions and 50 deletions

View file

@ -140,12 +140,10 @@ public static class ShipPrefabAuthoring
if (values.Length != 7) throw new InvalidOperationException($"Invalid pivot name {name}");
var pivot = AddOrGet<ArticulationPoint>(node.gameObject);
pivot.Group = ParseInt(values[1], name);
pivot.YawMin = ParseInt(values[2], name);
pivot.YawMax = ParseInt(values[3], name);
pivot.PitchMin = ParseInt(values[4], name);
pivot.PitchMax = ParseInt(values[5], name);
pivot.Speed = ParseInt(values[6], name);
if (pivot.YawMin > pivot.YawMax || pivot.PitchMin > pivot.PitchMax || pivot.Speed <= 0)
if (pivot.PitchMin > pivot.PitchMax || pivot.Speed <= 0)
throw new InvalidOperationException($"Invalid articulation range or speed in {name}");
pivots.Add(pivot);
}

View file

@ -4,15 +4,14 @@ using UnityEngine;
using CultMath;
using static CultMath.math;
// Presentation only: the barrel follows the point its group's solution places (EntityInstance), which FireControl has
// already clamped to the arc. This decides no arc; Speed and the pitch clamp are how the picture slews.
public class ArticulationPoint : MonoBehaviour
{
public Transform Target;
public int Group;
public float YawMin;
public float YawMax;
public float PitchMin;
public float PitchMax;
@ -30,7 +29,7 @@ public class ArticulationPoint : MonoBehaviour
var yaw = Vector2.SignedAngle(new Vector2(0, 1), new Vector2(targetLocal.x, targetLocal.z));
var pitch = Vector2.SignedAngle(new Vector2(1, 0), new Vector2(targetLocal.z, targetLocal.y));
var targetYaw = clamp(_yaw - yaw, YawMin, YawMax);
var targetYaw = _yaw - yaw;
var targetPitch = clamp(_pitch - pitch, PitchMin, PitchMax);
if (abs(targetYaw - _yaw) < Speed * Time.deltaTime)

View file

@ -64,6 +64,7 @@ public class EntityInstance : MonoBehaviour
public Dictionary<HardpointData, int> BarrelIndices { get; private set; }
public Dictionary<Radiator, MeshRenderer> RadiatorMeshes { get; private set; }
public Transform LookAtPoint { get; private set; }
private Dictionary<int, Transform> _groupAims;
public Entity Entity { get; private set; }
public ZoneRenderer ZoneRenderer { get; private set; }
public Transform LocalSpace { get; private set; }
@ -276,10 +277,14 @@ public class EntityInstance : MonoBehaviour
// HullColliders no longer publish Hit or a blast event at all (R8).
LookAtPoint = new GameObject($"{entity.Name} Look Point").transform;
// One aim point per articulation group, placed each LateUpdate where the group's first weapon aims.
_groupAims = new Dictionary<int, Transform>();
foreach (var articulationPoint in ArticulationPoints)
{
articulationPoint.Target = LookAtPoint;
if (!_groupAims.TryGetValue(articulationPoint.Group, out var aim))
_groupAims[articulationPoint.Group] = aim = new GameObject($"{entity.Name} Aim {articulationPoint.Group}").transform;
articulationPoint.Target = aim;
}
_subscriptions.Add(Entity.HullDamage.Subscribe(_ =>
@ -398,8 +403,18 @@ public class EntityInstance : MonoBehaviour
x.Value.material.SetFloat("_Emission", Entity.ItemManager.GameplaySettings.TemperatureEmissionCurve.Evaluate(x.Key.RadiatorTemperature));
}
LookAtPoint.position = transform.position + Entity.Aim.ToUnity() *
(!Entity.Target.Value.IsNone ? max(Entity.TargetRange,Entity.ItemManager.GameplaySettings.ConvergenceMinimumDistance) : 10000);
var aimDistance = !Entity.Target.Value.IsNone
? max(Entity.TargetRange, Entity.ItemManager.GameplaySettings.ConvergenceMinimumDistance)
: 10000;
LookAtPoint.position = transform.position + Entity.Aim.ToUnity() * aimDistance;
foreach (var group in _groupAims)
{
var weapon = group.Key >= 0 && group.Key < Entity.WeaponGroups.Length ? Entity.WeaponGroups[group.Key].weapons.FirstOrDefault() : null;
if (weapon == null) { group.Value.position = LookAtPoint.position; continue; }
var barrel = Barrels.TryGetValue(weapon.Item.Hardpoint, out var barrels) ? barrels[0].position : transform.position;
var direction = FireControl.Solution(weapon, Entity, Entity.Target.Value).Direction;
group.Value.position = barrel + new Vector3(direction.x, 0, direction.y) * aimDistance;
}
LocalSpace.localPosition = transform.position = Entity.Position.ToUnity();
if (_influenceInstance)
_influenceInstance.position = new Vector3(Entity.Position.x, 0, Entity.Position.z);

View file

@ -112,14 +112,10 @@ public abstract class Weapon : Behavior, IActivatedBehavior
// so a weapon is never safed against one.
public bool StanceAllowsFire => Entity.Target.Value.Entity == null || Entity.IsHostileTo(Entity.Target.Value.Entity);
// Cut 3 (docs/fire-control-cut.md, Q2): player fire is arc-gated like everyone else's. This is the same
// predicate (FireControl.InArc) Combat.cs and TurretController.cs gate AI and turret fire with, read at
// the one point every shooter's trigger passes through (InstantWeapon.Trigger) -- not a parallel check, and
// not the AI's own AgentMinHitProbability heuristic, which is a "worth it" decision layered above this
// gate, not the gate itself. With no target set, behaviour is unchanged (nothing to bear on).
// Cut 5, 5.4 (Soul finding 11): the point-blank special case this used to carry moved into InArc itself --
// one bearing test, one owner, so every caller (not just this one) gets a point-blank shot that bears.
// FireControl owns the gate, including its one exemption: a fused weapon fires out of arc.
// The trigger gate for player and AI shooters alike, read at the one point every shooter's trigger passes
// through (InstantWeapon.Trigger). A gun fires on its solution (FireControl.Solution) without the aim, else
// along the aim when the aim is inside its arc, else it holds. An AI shooter's aim is its intercept, so its
// fire is unchanged. FireControl owns the gate, including its exemption: a fused weapon fires out of arc.
public bool ArcAllowsFire => FireControl.ArcPermitsFire(this, Entity);
public Weapon(WeaponData data, EquippedItem item) : base(data, item)

View file

@ -67,24 +67,18 @@ public static class FireControl
return blastRadius > 0f ? data.Fuse : null;
}
// The one arc gate on the trigger. A fused weapon fires whatever the arc says: a target it cannot bear on is
// no reason to keep it silent, the round flies along the arc-clamped aim and bursts at the target's range
// (Fire; operator ruling 2026-09-30, "the user is hoping that the arc gets the weapon close enough to still
// splash some damage"). A weapon that only hits what it fires at still refuses. Fire reads InArc itself to
// choose that flight, so the two share the one bearing test and differ only in this exemption.
// Mining Cut 3: a chunk target bears through its own position, on the plane the bearing test reads.
// The one gate on the trigger (operator rulings fire-on-solutions, controls-solution-trigger, 2026-10-06). A gun
// fires on its solution without the aim, else free along the aim if the aim is inside its arc, else it holds.
// A fused weapon fires whatever the arc says: a target it cannot bear on is no reason to keep it silent, the
// round flies along the arc-clamped aim and bursts at the target's range (Fire; operator ruling 2026-09-30,
// "the user is hoping that the arc gets the weapon close enough to still splash some damage"). A mine layer
// lays a body, not a round, and is never arc-gated. The price of a free shot at a target that does not bear is
// HitProbability's: zero.
public static bool ArcPermitsFire(Weapon weapon, Entity shooter)
{
var target = shooter.Target.Value;
if (target.IsNone) return true;
float3 toTarget;
if (target.Chunk is ChunkId chunk)
{
var at = shooter.Zone.ChunkPose(chunk.Field, chunk.Index).xy;
toTarget = float3(at.x, 0, at.y) - shooter.Position;
}
else toTarget = target.Entity.Position - shooter.Position;
return FuseOf(weapon.Item, out _) != null || IsMineLayer(weapon) || InArc(weapon.Item, toTarget);
if (FuseOf(weapon.Item, out _) != null || IsMineLayer(weapon)) return true;
var solution = Solution(weapon, shooter, shooter.Target.Value);
return solution.Bears || solution.Free;
}
// Whether the shooter's next round from this weapon is refused: the weapons ask before they spend anything, so
@ -131,6 +125,33 @@ public static class FireControl
return float2(mount.x * cos(turn) - mount.y * sin(turn), mount.x * sin(turn) + mount.y * cos(turn));
}
// The one owner of a gun's solution against its subject (the designated target; ruling controls-solution-subject).
// Bears: the gun can engage the subject, so the player fires on it without the aim. Free: the aim is inside the
// arc, so the gun can fire along it. Direction: where a round from this gun travels and where its barrel points --
// the subject's intercept when it bears, else the arc-clamped aim. A chunk subject bears through its pose and
// flies the aim, as an untargeted round (Subject is null for it). Derived on every read and never stored: turrets
// traverse instantly in the simulation. Traverse speed belongs to the link (ruling arc-and-traverse-on-the-link),
// which will make this stateful behind the same signature. It never prices; HitProbability is the one price.
public static GunSolution Solution(Weapon weapon, Entity shooter, TargetRef subject)
{
var bears = false;
if (subject.Entity != null) bears = Bears(weapon, shooter, subject.Entity, out _);
else if (subject.Chunk is ChunkId chunk)
{
var at = shooter.Zone.ChunkPose(chunk.Field, chunk.Index).xy;
bears = InArc(weapon.Item, float3(at.x, 0, at.y) - shooter.Position);
}
return new GunSolution
{
Subject = subject.Entity,
Bears = bears,
Free = InArc(weapon.Item, shooter.Aim),
Direction = bears && subject.Entity != null
? TravelDirection(weapon, shooter, subject.Entity)
: AimDirection(weapon.Item, shooter)
};
}
// Cut 2, R5: whether `observer` has resolved `item` (which must belong to some entity in the zone) well
// enough to aim at it. Derived fresh from EntityInfoGathered on every call -- nothing caches a reveal
// result, so decay drops it the moment info falls back below tier, with no clearing loop anywhere.
@ -276,21 +297,26 @@ public static class FireControl
!(weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked);
}
// `designated` is the answer above. `inArc` is the bearing test, asked only once a target is designated.
// Whether this gun can engage this target: designated and inside the arc. The one test of it; PFire prices a shot
// at zero unless it holds, and Solution reads it to decide whether the gun fires on the target. `designated` is
// the answer above, kept apart for Solve's burst flight: a designated target out of arc still sets the burst range.
public static bool Bears(Weapon weapon, Entity source, Entity target, out float range) =>
Bears(weapon, source, target, out range, out _);
private static bool Bears(Weapon weapon, Entity source, Entity target, out float range, out bool designated)
{
designated = Designated(weapon, source, target, out range);
return designated && InArc(weapon.Item, target.Position - source.Position);
}
// `designated` is the answer above. `inArc` is Bears, asked only once a target is designated.
// PFire prices a shot at zero unless both hold. Fire reads them to decide what a fused round flies at
// (operator rulings 2026-09-30): without valid data it has no target and bursts at max range; with data but
// out of arc it bursts at the target's range, along the aim. The HUD reads Designated and the
// arc from Solve and Inspect and decides nothing.
private static float PFire(Weapon weapon, Entity source, Entity target, out float range, out bool designated, out bool inArc)
{
range = 0f;
designated = false;
inArc = false;
if (target == null) return 0f;
designated = Designated(weapon, source, target, out range);
if (!designated) return 0f;
inArc = InArc(weapon.Item, target.Position - source.Position);
inArc = Bears(weapon, source, target, out range, out designated);
if (!inArc) return 0f;
var settings = source.ItemManager.GameplaySettings;
@ -615,7 +641,7 @@ public static class FireControl
// burst's later rounds and a power-starved Range included).
var engaged = fuse != null && !(designated && inArc) ? null : target;
var origin = source.Position;
var travelDirection = engaged != null ? TravelDirection(weapon, source, engaged) : AimDirection(item, source);
var travelDirection = Solution(weapon, source, target).Direction;
var arming = fuse != null ? blastRadius : 0f;
var burstPosition = engaged != null ? engaged.Position : origin;
@ -1989,6 +2015,16 @@ public enum FireOutcome
Refused
}
// A gun's solution against its subject (FireControl.Solution): whether the gun bears on the subject, whether the
// aim is free (inside the arc), and the direction a round from it travels.
public struct GunSolution
{
public Entity Subject;
public bool Bears;
public bool Free;
public float2 Direction;
}
// Solve's answer. Outcome and BurstReach (planar distance from the shooter to the burst point, read for Burst
// only) are what the HUD shows; the rest is what Fire freezes into the PendingShot.
public struct FireSolution

View file

@ -4,8 +4,9 @@ using static ItemRotation;
public sealed class Arcs : Scenario
{
public override string Name => "Arcs";
public override string Brief => "Forward mounts, a bare hull off the bow, one off the stern, a hostile turret. Verify: the bow " +
"hull can be hit, nothing fires at the stern one through the ship, the turret tracks you all the way round.";
public override string Brief => "Forward mounts, a bare hull off the bow, one off the stern, a hostile turret. Verify: the hull keeps its " +
"heading while you aim at the stern hull, and the forward guns hold. Designate the bow hull and fire with the " +
"aim anywhere: the forward guns fire on it and hit. The turret tracks you all the way round.";
public override uint Seed => 103;
public override bool Ambient => false;

View file

@ -0,0 +1,37 @@
using static CultMath.math;
using static ItemRotation;
public sealed class Broadside : Scenario
{
public override string Name => "Broadside";
public override string Brief => "A hull with a port gun and a starboard gun in separate groups, bare hulls abeam to port and starboard and one ahead. " +
"Verify: designate the starboard hull, aim ahead and fire the starboard group: it fires on the hull without the aim. " +
"Fire the port group: it holds, because nothing bears and the aim is outside its arc. Undesignate and aim to port: " +
"the port group fires free along the aim.";
public override uint Seed => 104;
public override bool Ambient => false;
// Where the targets sit: the player is at the origin facing +z.
public static readonly CultMath.float2 Port = float2(-400, 0), Starboard = float2(400, 0), Ahead = float2(0, 400);
public override Galaxy Generate(GalaxyStage stage) => stage.Prelude();
public override void Stage(ScenarioStage stage)
{
var broadside = stage.Fit("LonginusX",
("Cockpit 2x2", int2(2, 6), None),
("Traction", int2(2, 3), None),
("Core Power", int2(2, 1), None),
("GT 3K", int2(0, 5), CounterClockwise),
("FastBlast+-", int2(4, 8), Clockwise),
("Iapyx", int2(1, 2), CounterClockwise),
("Iapyx", int2(4, 2), Clockwise),
("not if i see you first", int2(3, 10), None),
("Fire Control Array", int2(2, 5), None));
stage.Player(broadside, float2(0, 0), facing: float2(0, 1));
stage.Place(stage.Bare("LonginusX"), Port);
stage.Place(stage.Bare("LonginusX"), Starboard);
stage.Place(stage.Bare("LonginusX"), Ahead);
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9fb4b5cf800b4ccdae5d6c15448df36e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -6,6 +6,6 @@ public static class Scenarios
public static readonly Scenario[] Tests =
{
new DjinniShakedown(), new StarvedReactor(), new Arcs(), new Duel(), new LauncherAngles(), new LongHaul()
new DjinniShakedown(), new StarvedReactor(), new Arcs(), new Broadside(), new Duel(), new LauncherAngles(), new LongHaul()
};
}

View file

@ -23,7 +23,7 @@ using int2 = CultMath.int2;
// follows, with the rules this cut's own tests must obey: SchematicCellSize 2 throughout (the 12.3 fixture's
// cell size of 1 would hide a missing `/ SchematicCellSize`), the target off the origin, and a hull whose
// centre of mass is not an integer cell.
public sealed class FireControlCut124Tests : IDisposable
public sealed partial class FireControlCut124Tests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut124-" + Guid.NewGuid().ToString("N"));
private string Catalog => Path.Combine(_root, "Aetheria.cc");
@ -1397,7 +1397,7 @@ public sealed class FireControlCut124Tests : IDisposable
var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 4f, damage: 100f,
weaponRange: NoLockRange, targetRange: 40f);
e.Shooter.Direction = float2(0, -1);
Aim(e, float2(0, -1));
Aim(e, float2(0, 1)); // outside the flipped mount's arc too: no solution, and the aim is not free
Assert.Equal(fires, e.Weapon.ArcAllowsFire);
var committed = 0;
using var c = e.Zone.ShotCommitted.Subscribe(_ => committed++);

View file

@ -719,6 +719,7 @@ public sealed partial class MiningCut3Tests : IDisposable
Tick((shooter, eyeAt));
var gun = shooter.Weapons.Single();
shooter.Aim = float3(0, 0, -1); // astern: only a solution can fire the gun
Assert.True(shooter.SetTarget(ahead));
Assert.True(gun.ArcAllowsFire);
Assert.True(gun.StanceAllowsFire);
@ -728,6 +729,26 @@ public sealed partial class MiningCut3Tests : IDisposable
Assert.True(gun.StanceAllowsFire);
}
// The same bearing through the solution: a chunk ahead is a solution, one to the side is not, and a chunk is a
// solution without a subject entity (its round flies the aim, as an untargeted one).
[Fact]
public void AChunkTargetBearsByItsPose()
{
var kind = Kind("Asteroid", 4f);
var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f, .25f), Rock(150f)));
var eyeAt = float2(-10, 50);
var shooter = SpawnShip(s, eyeAt, sensor: true, weaponRanges: 300f);
Tick((shooter, eyeAt));
var gun = shooter.Weapons.Single();
var ahead = FireControl.Solution(gun, shooter, new ChunkId(s.Belts[0], 0));
var aside = FireControl.Solution(gun, shooter, new ChunkId(s.Belts[0], 1));
Assert.True(ahead.Bears);
Assert.Null(ahead.Subject);
Assert.False(aside.Bears);
}
// A writable copy of the shipped catalog plus a scratch run store, through a registry scoped to the shipped
// assembly's own document types (RestoredHullsTests' composition): this test assembly's own catalog global
// would otherwise be demanded of a catalog that was never authored with it.

View file

@ -500,6 +500,32 @@ public sealed partial class RunStartTests
Assert.False(player.IsHostileTo(bow) || player.IsHostileTo(stern), "the bare hulls are neutral");
}
// Broadside's geometry: the player carries guns on both sides in separate groups. Each side's gun bears on the hull
// abeam on its side and on nothing else, and the hull ahead bears for neither.
[Fact]
public void BroadsideGeometry()
{
var staged = Stage(Arena(false), new Broadside());
var player = staged.Player;
var port = staged.Entities[0];
var starboard = staged.Entities[1];
var ahead = staged.Entities[2];
Assert.Equal((Broadside.Port, Broadside.Starboard, Broadside.Ahead), (port.Position.xz, starboard.Position.xz, ahead.Position.xz));
var groups = player.WeaponGroups.Where(group => group.items.Count > 0).ToList();
Assert.True(groups.Count >= 2, "the guns must sit in separate groups");
var portGun = Assert.Single(groups.SelectMany(group => group.items).Where(item => item.EquippableItem.Rotation == ItemRotation.CounterClockwise && item.Behaviors.OfType<Weapon>().Any()));
var starboardGun = Assert.Single(groups.SelectMany(group => group.items).Where(item => item.EquippableItem.Rotation == ItemRotation.Clockwise && item.Behaviors.OfType<Weapon>().Any()));
Assert.NotEqual(groups.Single(group => group.items.Contains(portGun)).items, groups.Single(group => group.items.Contains(starboardGun)).items);
Assert.True(FireControl.InArc(portGun, port.Position - player.Position));
Assert.False(FireControl.InArc(portGun, starboard.Position - player.Position));
Assert.False(FireControl.InArc(portGun, ahead.Position - player.Position));
Assert.True(FireControl.InArc(starboardGun, starboard.Position - player.Position));
Assert.False(FireControl.InArc(starboardGun, port.Position - player.Position));
Assert.False(FireControl.InArc(starboardGun, ahead.Position - player.Position));
}
// Q3 (operator, 2026-09-30): a design no product sells is a scenario test design and nothing else. A test design
// is one some scenario's staged entities carry, fitted or in cargo. These are the unsold designs none carries.
private List<string> UnsoldAndUnplaced(IEnumerable<RunStart.Staged> runs)

View file

@ -0,0 +1,180 @@
/* 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 CultMath;
using Xunit;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
// Controls 2 (rulings fire-on-solutions, controls-solution-subject, controls-solution-trigger): FireControl.Solution is
// each gun's one solution against the designated target, and ArcPermitsFire is the one trigger gate over it. The
// fixture is FireControlCut124Tests': a forward mount at the origin facing +z, the designated target 100 ahead.
public sealed partial class FireControlCut124Tests
{
private const float SolutionArc = 120f;
private static GunSolution SolutionOf(Engagement e) => FireControl.Solution(e.Weapon, e.Shooter, e.Shooter.Target.Value);
// A designated target in the arc is fired on whatever the aim says; the round goes to its intercept, not the aim.
// Kills an aim-only gate (the astern aim holds) and a direction that ignores the subject.
[Fact]
public void ASolutionFiresWithoutTheAim()
{
var e = Build(TestSettings(), SolidShape(5, 4), hardpointArc: SolutionArc);
Aim(e, float2(0, -1));
var solution = SolutionOf(e);
Assert.True(FireControl.ArcPermitsFire(e.Weapon, e.Shooter));
Assert.True(solution.Bears);
Assert.False(solution.Free);
Assert.Same(e.Target, solution.Subject);
Near(FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target), solution.Direction);
Near(float2(0, 1), solution.Direction);
}
[Fact]
public void FreeFireNeedsTheAimInArc()
{
var e = Build(TestSettings(), SolidShape(5, 4), hardpointArc: SolutionArc);
e.Shooter.SetTarget(TargetRef.None);
Aim(e, float2(0, 1));
Assert.True(FireControl.ArcPermitsFire(e.Weapon, e.Shooter));
var free = SolutionOf(e);
Assert.True(free.Free);
Assert.False(free.Bears);
Near(float2(0, 1), free.Direction);
Aim(e, float2(0, -1));
Assert.False(FireControl.ArcPermitsFire(e.Weapon, e.Shooter));
Assert.False(SolutionOf(e).Free);
}
// A designated target 90 degrees off a 120 degree forward mount gives no solution. With the aim astern the gun
// holds; with the aim ahead it fires free along the aim, and the price of that shot is zero.
[Fact]
public void ATargetOutOfArcGivesNoSolution()
{
var e = Build(TestSettings(), SolidShape(5, 4), hardpointArc: SolutionArc, weaponRange: 200f);
e.Target.Position = e.Shooter.Position + float3(100, 0, 0);
Aim(e, float2(0, -1));
Assert.False(SolutionOf(e).Bears);
Assert.False(FireControl.ArcPermitsFire(e.Weapon, e.Shooter));
Aim(e, float2(0, 1));
var solution = SolutionOf(e);
Assert.False(solution.Bears);
Assert.True(solution.Free);
Assert.True(FireControl.ArcPermitsFire(e.Weapon, e.Shooter));
Near(float2(0, 1), solution.Direction);
Assert.Equal(0f, FireControl.HitProbability(e.Weapon, e.Shooter, e.Target));
}
[Fact]
public void AFusedWeaponIgnoresTheGate()
{
var e = Build(TestSettings(), SolidShape(5, 4), fuse: WeaponFuse.Proximity, blastRadius: 4f, weaponRange: NoLockRange, hardpointArc: SolutionArc);
e.Shooter.SetTarget(TargetRef.None);
Aim(e, float2(0, -1));
var solution = SolutionOf(e);
Assert.False(solution.Bears);
Assert.False(solution.Free);
Assert.True(FireControl.ArcPermitsFire(e.Weapon, e.Shooter));
}
// HitProbability above zero implies Bears and no Bears means no price, over bearings, ranges and visibility around
// a weapon of range 100: the price's gate and the solution's are the one Bears. The grid is checked to price
// something, so a gate that closed everything could not pass it.
[Fact]
public void BearsIsThePricesGate()
{
var e = Build(TestSettings(), SolidShape(5, 4), hardpointArc: SolutionArc, weaponRange: 100f);
var priced = 0;
foreach (var degrees in new[] { 0f, 30f, 59f, 61f, 90f, 135f, 180f })
foreach (var range in new[] { 20f, 99f, 101f, 150f })
foreach (var visible in new[] { true, false })
{
var at = radians(degrees);
e.Target.Position = e.Shooter.Position + float3(sin(at), 0, cos(at)) * range;
if (visible && !e.Shooter.VisibleEntities.Contains(e.Target)) e.Shooter.VisibleEntities.Add(e.Target);
if (!visible) e.Shooter.VisibleEntities.Remove(e.Target);
var bears = FireControl.Bears(e.Weapon, e.Shooter, e.Target, out _);
var price = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target);
Assert.Equal(bears, SolutionOf(e).Bears);
if (price > 0f) Assert.True(bears, $"priced {price} at {degrees} degrees, {range} out, visible {visible}, without bearing");
if (!bears) Assert.Equal(0f, price);
if (price > 0f) priced++;
}
Assert.True(priced > 0, "fixture: nothing on the grid was priced");
}
// Each branch of what a round does flies along Solution.Direction as it stood at fire time: a target that bears
// (aim ignored), an unfused round at a designated target out of arc (the clamped aim), a fused round with no lock.
[Fact]
public void SolveFliesAlongTheSolution()
{
var bearing = Build(TestSettings(), SolidShape(5, 4), hardpointArc: SolutionArc);
Aim(bearing, float2(0, -1));
Assert.True(SolutionOf(bearing).Bears);
AssertFlies(bearing);
var outOfArc = Build(TestSettings(), SolidShape(5, 4), hardpointArc: SolutionArc, weaponRange: 200f);
outOfArc.Target.Position = outOfArc.Shooter.Position + float3(100, 0, 0);
Aim(outOfArc, float2(1, 4));
Assert.False(SolutionOf(outOfArc).Bears);
AssertFlies(outOfArc);
var fused = Build(TestSettings(), SolidShape(5, 4), fuse: WeaponFuse.Proximity, blastRadius: 4f, weaponRange: NoLockRange, hardpointArc: SolutionArc);
fused.Shooter.SetTarget(TargetRef.None);
Aim(fused, float2(0, -1));
AssertFlies(fused);
}
private static void AssertFlies(Engagement e)
{
var direction = SolutionOf(e).Direction;
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
Near(direction, SafeAssert.OnlyShot(e.Zone).TravelDirection);
}
[Fact]
public void AFullArcTracksTheAimAllRound()
{
var settings = TestSettings();
settings.FiringArc = 360;
var e = Build(settings, SolidShape(5, 4));
e.Shooter.SetTarget(TargetRef.None);
for (var degrees = 0; degrees < 360; degrees += 30)
{
var aim = float2(sin(radians(degrees)), cos(radians(degrees)));
Aim(e, aim);
Assert.True(FireControl.ArcPermitsFire(e.Weapon, e.Shooter));
Near(aim, SolutionOf(e).Direction);
}
}
// An agent's aim is its intercept (Combat sets it), so an in-arc target is a solution and the free aim both: its
// fire is the same with or without the aim.
[Fact]
public void TheAgentStillFiresAtItsIntercept()
{
var e = Build(TestSettings(), SolidShape(5, 4), hardpointArc: SolutionArc);
var intercept = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target);
Aim(e, intercept);
var solution = SolutionOf(e);
Assert.True(solution.Bears && solution.Free);
Assert.True(FireControl.ArcPermitsFire(e.Weapon, e.Shooter));
Assert.True(FireControl.AgentFires(e.Weapon, e.Shooter, e.Target));
Near(intercept, solution.Direction);
}
}