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:
parent
4d664b537f
commit
e103b90aea
13 changed files with 370 additions and 50 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
37
Assets/Scripts/ServerShared/Scenarios/Broadside.cs
Normal file
37
Assets/Scripts/ServerShared/Scenarios/Broadside.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/ServerShared/Scenarios/Broadside.cs.meta
Normal file
11
Assets/Scripts/ServerShared/Scenarios/Broadside.cs.meta
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 9fb4b5cf800b4ccdae5d6c15448df36e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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()
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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++);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
180
tests/Aetheria.Shared.Tests/SolutionTests.cs
Normal file
180
tests/Aetheria.Shared.Tests/SolutionTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue