From e103b90aea4da9d32f86e43b5b08aee00d973351 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 13:25:53 +0200 Subject: [PATCH] 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 --- Assets/Scripts/Editor/ShipPrefabAuthoring.cs | 4 +- Assets/Scripts/Gameplay/ArticulationPoint.cs | 7 +- Assets/Scripts/Gameplay/EntityInstance.cs | 23 ++- .../Scripts/ServerShared/Behaviors/Weapon.cs | 12 +- Assets/Scripts/ServerShared/FireControl.cs | 88 ++++++--- Assets/Scripts/ServerShared/Scenarios/Arcs.cs | 5 +- .../ServerShared/Scenarios/Broadside.cs | 37 ++++ .../ServerShared/Scenarios/Broadside.cs.meta | 11 ++ .../ServerShared/Scenarios/Scenarios.cs | 2 +- .../FireControlCut124Tests.cs | 4 +- .../Aetheria.Shared.Tests/MiningCut3Tests.cs | 21 ++ tests/Aetheria.Shared.Tests/ScenarioTests.cs | 26 +++ tests/Aetheria.Shared.Tests/SolutionTests.cs | 180 ++++++++++++++++++ 13 files changed, 370 insertions(+), 50 deletions(-) create mode 100644 Assets/Scripts/ServerShared/Scenarios/Broadside.cs create mode 100644 Assets/Scripts/ServerShared/Scenarios/Broadside.cs.meta create mode 100644 tests/Aetheria.Shared.Tests/SolutionTests.cs diff --git a/Assets/Scripts/Editor/ShipPrefabAuthoring.cs b/Assets/Scripts/Editor/ShipPrefabAuthoring.cs index 5d5b7352c..d2937b79d 100644 --- a/Assets/Scripts/Editor/ShipPrefabAuthoring.cs +++ b/Assets/Scripts/Editor/ShipPrefabAuthoring.cs @@ -140,12 +140,10 @@ public static class ShipPrefabAuthoring if (values.Length != 7) throw new InvalidOperationException($"Invalid pivot name {name}"); var pivot = AddOrGet(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); } diff --git a/Assets/Scripts/Gameplay/ArticulationPoint.cs b/Assets/Scripts/Gameplay/ArticulationPoint.cs index 427b74c0f..10648f09a 100644 --- a/Assets/Scripts/Gameplay/ArticulationPoint.cs +++ b/Assets/Scripts/Gameplay/ArticulationPoint.cs @@ -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) diff --git a/Assets/Scripts/Gameplay/EntityInstance.cs b/Assets/Scripts/Gameplay/EntityInstance.cs index fb356dd75..be0cfba9e 100644 --- a/Assets/Scripts/Gameplay/EntityInstance.cs +++ b/Assets/Scripts/Gameplay/EntityInstance.cs @@ -64,6 +64,7 @@ public class EntityInstance : MonoBehaviour public Dictionary BarrelIndices { get; private set; } public Dictionary RadiatorMeshes { get; private set; } public Transform LookAtPoint { get; private set; } + private Dictionary _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(); 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); diff --git a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs index 82febb067..2093a8598 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs @@ -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) diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 50b8a3b7f..138776ce9 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -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 diff --git a/Assets/Scripts/ServerShared/Scenarios/Arcs.cs b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs index d1ceb61e0..90a87e26c 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Arcs.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs @@ -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; diff --git a/Assets/Scripts/ServerShared/Scenarios/Broadside.cs b/Assets/Scripts/ServerShared/Scenarios/Broadside.cs new file mode 100644 index 000000000..8634b3394 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/Broadside.cs @@ -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); + } +} diff --git a/Assets/Scripts/ServerShared/Scenarios/Broadside.cs.meta b/Assets/Scripts/ServerShared/Scenarios/Broadside.cs.meta new file mode 100644 index 000000000..439908d94 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/Broadside.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9fb4b5cf800b4ccdae5d6c15448df36e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Scenarios/Scenarios.cs b/Assets/Scripts/ServerShared/Scenarios/Scenarios.cs index 31890d789..935aa103f 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Scenarios.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Scenarios.cs @@ -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() }; } diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index c6b1bb00c..6d84bd6ce 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -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++); diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index 496955b98..06cfb077f 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -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. diff --git a/tests/Aetheria.Shared.Tests/ScenarioTests.cs b/tests/Aetheria.Shared.Tests/ScenarioTests.cs index cfd5cc174..f24cc8d38 100644 --- a/tests/Aetheria.Shared.Tests/ScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/ScenarioTests.cs @@ -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().Any())); + var starboardGun = Assert.Single(groups.SelectMany(group => group.items).Where(item => item.EquippableItem.Rotation == ItemRotation.Clockwise && item.Behaviors.OfType().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 UnsoldAndUnplaced(IEnumerable runs) diff --git a/tests/Aetheria.Shared.Tests/SolutionTests.cs b/tests/Aetheria.Shared.Tests/SolutionTests.cs new file mode 100644 index 000000000..4418569c7 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SolutionTests.cs @@ -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); + } +}