From 848ff83cd770d95b49d9d2ccc52bdd987d0be15b Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 13:27:52 +0200 Subject: [PATCH 1/6] Combat releases a held AutoWeapon it no longer pulls: no target, no track, other group, exit The weapon holds a down trigger; Combat's early returns issued no release and had no exit hook, so a held Slug Rifle, Autocannon or Flak Gun fired on at nothing. Combat is the one owner of the AI's trigger level: every held AutoWeapon it does not pull this step is released. A held gun stays a group candidate between rounds so cooling does not drop it. Tests run through Minion/Combat. Co-Authored-By: Claude Sonnet 5.5 --- .../ServerShared/Agents/States/Combat.cs | 21 +++- .../FireControlPerWeaponTests.cs | 95 +++++++++++++++++++ 2 files changed, 113 insertions(+), 3 deletions(-) diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index f8c12baab..045778f4e 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -47,9 +47,9 @@ public class CombatState : BaseState // only a contact a member tracks has a track to fly on, so an untracked target is no target here. var view = _agent.View; var target = view.TargetOf(_agent.Ship); - if (target == null) return; + if (target == null) { ReleaseUnwanted(); return; } var track = view.TrackOf(_agent.Ship, target.Value); - if (track == null) return; + if (track == null) { ReleaseUnwanted(); return; } _availableGroups.Clear(); _availableLockingWeapons.Clear(); @@ -88,7 +88,9 @@ public class CombatState : BaseState view.Designated(_agent.Ship, weapon, target.Value) && !view.Refuses(_agent.Ship, weapon, target.Value) && (weapon is ConstantWeapon || - weapon is InstantWeapon instantWeapon && instantWeapon.CanFire)) + // A held AutoWeapon stays a candidate between its rounds (it is cooling because it is firing), so the + // group it belongs to is judged by the same Fires verdict below, not dropped for cooling down. + weapon is InstantWeapon instantWeapon && (instantWeapon.CanFire || weapon is AutoWeapon && weapon.Firing))) { if(weapon is LockWeapon lockWeapon) _availableLockingWeapons.Add(lockWeapon); @@ -151,6 +153,7 @@ public class CombatState : BaseState _agent.Ship.Command.Release(weapon); } } + ReleaseUnwanted(); _agent.Ship.Command.Aim = toTarget; _agent.Ship.Command.Turn = TurnCommand.Toward(toTarget.xz); _agent.Accelerate(movementDirection * _agent.TopSpeed, true);//selectedGroup >= 0); @@ -158,6 +161,18 @@ public class CombatState : BaseState // Fire charged guns! } + public override void OnExitState() => ReleaseUnwanted(); + + // The AI's trigger level for an AutoWeapon is what it pulls this step: the weapon holds a down trigger until it is + // released, so every held one this step does not pull is let go, whether the target is gone, untracked, out of reach, + // its group is not the pick, or Combat has ended. A plain gun is tapped and lets go of itself. + private void ReleaseUnwanted() + { + var command = _agent.Ship.Command; + foreach (var weapon in _agent.Ship.Weapons) + if (weapon is AutoWeapon && weapon.Firing && !command.Pulls(weapon)) command.Release(weapon); + } + // Overdrive to close a distant target ahead, through the same consumable gate as the vent (Agent.VentOnTrack). private void UseConsumables(float3 toTarget, float targetDistance) { diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 4b7b7027b..6897f0f9a 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -1041,6 +1041,101 @@ public sealed class FireControlPerWeaponTests : IDisposable if (shots == 0) Assert.Equal(60, readyTicks); } + // ==== The AI's trigger level for an AutoWeapon ==== + + public enum Loss { NoTarget, Untracked } + + private static void LoseTheTarget(Rig r, Loss loss) + { + if (loss == Loss.NoTarget) r.Shooter.SetTarget(TargetRef.None); + else r.Shooter.VisibleEntities.Remove(r.Target); + } + + // A pilot that pulled an AutoWeapon and then loses its target, or its track of it, lets the trigger go: the weapon holds + // a down trigger until told otherwise, and an early return that issued no release left it firing on at nothing. Through + // the whole agent (Combat, then the transition out of it), and through Combat's Update alone. Kills: Combat never + // releasing, releasing only when the target is gone (not when it is untracked), releasing a step late. + [Theory] + [InlineData(Loss.NoTarget)] + [InlineData(Loss.Untracked)] + public void ACombatPilotThatLosesItsTargetReleasesItsHeldAutoWeaponAndItFiresNoMore(Loss loss) + { + var r = Build(auto: true); + var gun = (InstantWeapon) r.Gun.Weapon; + var rounds = 0; + gun.OnFire += _ => rounds++; + r.Zone.Agents.Add(new Minion(r.Shooter, FactionDoctrine.Default(r.Shooter.ItemManager.GameplaySettings).Combatant)); + const float dt = 1f / 30f; + for (var i = 0; i < 90; i++) r.Zone.Update(dt); + Assert.True(gun.Firing, "control: the pilot holds the gun while it has a target"); + Assert.True(rounds >= 2, $"control: {rounds} rounds in 3 s at a 1 s cooldown"); + + LoseTheTarget(r, loss); + r.Zone.Update(dt); + var after = rounds; + Assert.False(gun.Firing, "the trigger is let go the step the target is lost"); + for (var i = 0; i < 90; i++) r.Zone.Update(dt); + Assert.Equal(after, rounds); + } + + [Theory] + [InlineData(Loss.NoTarget)] + [InlineData(Loss.Untracked)] + public void CombatUpdateReleasesAHeldAutoWeaponWhenItHasNoTargetOrNoTrack(Loss loss) + { + var r = Build(auto: true); + var gun = (InstantWeapon) r.Gun.Weapon; + var combat = new CombatState(new Minion(r.Shooter, FactionDoctrine.Default(r.Shooter.ItemManager.GameplaySettings).Combatant)); + gun.Activate(); + Assert.True(gun.Firing, "control: the trigger is down"); + LoseTheTarget(r, loss); + + combat.Update(.1f); + r.Zone.Update(.1f); + + Assert.False(gun.Firing); + } + + // Combat's end lets go of what it held, wherever the end comes from. + [Fact] + public void CombatExitReleasesAHeldAutoWeapon() + { + var r = Build(auto: true); + var gun = (InstantWeapon) r.Gun.Weapon; + var combat = new CombatState(new Minion(r.Shooter, FactionDoctrine.Default(r.Shooter.ItemManager.GameplaySettings).Combatant)); + gun.Activate(); + Assert.True(gun.Firing, "control: the trigger is down"); + + combat.OnExitState(); + r.Zone.Update(.1f); + + Assert.False(gun.Firing); + } + + // A held AutoWeapon is cooling between its rounds because it is firing: its group is not dropped for cooling down, or + // the trigger would be let go and pressed again at step granularity. Kills: releasing a held gun whose cooldown is + // running while its target stands. + [Fact] + public void CombatKeepsAnAutoWeaponHeldThroughItsCooldownWhileTheTargetStands() + { + var r = Build(auto: true); + var gun = (InstantWeapon) r.Gun.Weapon; + var rounds = 0; + gun.OnFire += _ => rounds++; + r.Zone.Agents.Add(new Minion(r.Shooter, FactionDoctrine.Default(r.Shooter.ItemManager.GameplaySettings).Combatant)); + const float dt = 1f / 30f; + for (var i = 0; i < 60; i++) r.Zone.Update(dt); + var cooling = 0; + for (var i = 0; i < 240; i++) + { + r.Zone.Update(dt); + Assert.True(gun.Firing, $"step {i}: the held gun was let go with its target standing"); + if (!gun.CanFire) cooling++; + } + Assert.True(cooling > 100, $"{cooling} of 240 steps spent cooling"); + Assert.InRange(rounds, 8, 10); + } + // Kills: Trigger that leaves _burstStarted true from the previous burst, so the next burst reads as started // (and ready) before its first round has flown. [Fact] From 65b0e00cd606ae65a920fa34870ba12c360811a0 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 13:27:53 +0200 Subject: [PATCH 2/6] Test: a behaviour created from a consumable AutoWeaponData honours Automatic Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/LoadoutTests.cs | 26 +++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index fca4ba339..3697d7345 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -967,6 +967,32 @@ public sealed class LoadoutTests : IDisposable Assert.Equal(1f, effect.Evaluate(undeclared), 3); } + // Creating a behaviour reads the fire mode from the data wherever the weapon is carried: a consumable's automatic gun is an + // AutoWeapon that repeats, every other kind a plain semi-automatic InstantWeapon. Kills: the consumable CreateInstance + // ignoring Automatic. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void CreatingABehaviourFromAConsumableHonoursAutomatic(bool automatic) + { + using var cache = AetheriaStores.Open(Catalog, catalogWritable: true); + var maker = cache.RefOf(cache.GetByName("Maker")); + var hullData = cache.GetByName("Skiff"); + InstantWeaponData gun = automatic ? new AutoWeaponData() : new InstantWeaponData(); + var consumableRef = cache.Upsert(new ConsumableItemData { Name = "Dose", Duration = 10, Behaviors = { gun } }); + cache.FlushAsync().Wait(); + + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var hullItem = (EquippableItem) items.CreateInstance(items.CreateLot(hullData, maker, .5f)); + var ship = new Ship(items, null, hullItem, new EntitySettings()); + var consumableItem = (ConsumableItem) items.CreateInstance(items.CreateLot(cache.Get(consumableRef), maker, .5f)); + + var behavior = new ConsumableItemEffect(consumableItem, ship).Behaviors.Single(); + + Assert.True(automatic == (behavior is AutoWeapon), $"automatic {automatic}: created a {behavior.GetType().Name}"); + Assert.IsAssignableFrom(behavior); + } + // F4: CreateLot(product) fills each of the design's roles from the product's own per-role spread // (FactionProductData.Roles), not the design-wide default. [Fact] From d2c293b799418d5d406691778a8af9fa648983a5 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 13:27:54 +0200 Subject: [PATCH 3/6] Test: the semi-auto floor is the 0.1 s constant, not a multiple of the step Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/SemiAutoCatalogTests.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/SemiAutoCatalogTests.cs b/tests/Aetheria.Shared.Tests/SemiAutoCatalogTests.cs index 8bedd9bd0..8c83ce331 100644 --- a/tests/Aetheria.Shared.Tests/SemiAutoCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/SemiAutoCatalogTests.cs @@ -83,4 +83,17 @@ public sealed class SemiAutoCatalogTests Assert.True(auto.Automatic); Assert.Empty(InstantWeaponData.SemiAutoFaults(new ItemData[] { Gun("Auto", auto), new ConsumableItemData { Name = "Auto dose", Behaviors = { auto } } })); } + + // The floor is the 0.1 s constant itself, not a function of the tick: a 0.08 s semi-auto gun is refused at a step length + // whose own multiple (2 x GameStep at 30 Hz is 0.067 s) would let it through, and refused at the constant's edge from + // below however the step moves. Kills: SemiAutoFaults comparing against a step-derived value. + [Fact] + public void TheFloorIsTheConstantNotAMultipleOfTheStep() + { + Assert.NotEqual(InstantWeaponData.ShortestSemiAutoCooldown, SimClock.GameStep * 2f); + Assert.True(SimClock.GameStep * 2f < .08f, "fixture: a step-derived floor would let 0.08 s through"); + Assert.Single(InstantWeaponData.SemiAutoFaults(new[] { Gun("Eighty", new InstantWeaponData { Cooldown = Const(.08f) }) })); + Assert.Single(InstantWeaponData.SemiAutoFaults(new[] { Gun("Just under", new InstantWeaponData { Cooldown = Const(.0999f) }) })); + Assert.Empty(InstantWeaponData.SemiAutoFaults(new[] { Gun("Just over", new InstantWeaponData { Cooldown = Const(.1001f) }) })); + } } From d4bec37c6a6948c922a0f1c3a27242014591697d Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 13:34:18 +0200 Subject: [PATCH 4/6] Combat's release sweep leaves a weapon it already commanded alone: PilotCommand.Commands One command per weapon per step: the sweep released a gun the group loop had already released. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Agents/States/Combat.cs | 6 +++--- Assets/Scripts/ServerShared/Pilot.cs | 8 ++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index 045778f4e..7ee302dbd 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -163,14 +163,14 @@ public class CombatState : BaseState public override void OnExitState() => ReleaseUnwanted(); - // The AI's trigger level for an AutoWeapon is what it pulls this step: the weapon holds a down trigger until it is - // released, so every held one this step does not pull is let go, whether the target is gone, untracked, out of reach, + // The AI's trigger level for an AutoWeapon is what it commands this step: the weapon holds a down trigger until it is + // released, so every held one this step has no command for is let go, whether the target is gone, untracked, out of reach, // its group is not the pick, or Combat has ended. A plain gun is tapped and lets go of itself. private void ReleaseUnwanted() { var command = _agent.Ship.Command; foreach (var weapon in _agent.Ship.Weapons) - if (weapon is AutoWeapon && weapon.Firing && !command.Pulls(weapon)) command.Release(weapon); + if (weapon is AutoWeapon && weapon.Firing && !command.Commands(weapon)) command.Release(weapon); } // Overdrive to close a distant target ahead, through the same consumable gate as the vent (Agent.VentOnTrack). diff --git a/Assets/Scripts/ServerShared/Pilot.cs b/Assets/Scripts/ServerShared/Pilot.cs index dcfa655ae..0ef20f96c 100644 --- a/Assets/Scripts/ServerShared/Pilot.cs +++ b/Assets/Scripts/ServerShared/Pilot.cs @@ -94,6 +94,14 @@ public sealed class PilotCommand // The pilot's own pending decisions, which it reads back instead of the ship (that is last step's applied state). public TargetRef? Designated => Designations.Count > 0 ? Designations[Designations.Count - 1] : (TargetRef?) null; + // Whether this pilot has commanded a trigger, pull or release, for `behavior` this step. + public bool Commands(ActivatedBehavior behavior) + { + foreach (var trigger in Triggers) + if (ReferenceEquals(trigger.Behavior, behavior)) return true; + return false; + } + // Whether the last trigger this pilot commanded for `behavior` this step is a pull. public bool Pulls(ActivatedBehavior behavior) { From 3715c9edf0303918752cf68bd5c2c07657f4bf12 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 13:39:43 +0200 Subject: [PATCH 5/6] Repin the two-AI digest once: Combat releases a held AutoWeapon it no longer commands Measured 85e85c46 on linux-x64 (55ecd858 before), on two full runs. Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs index 07f684727..fd5278acf 100644 --- a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -43,7 +43,10 @@ public sealed partial class RunStartTests // order, ties by shot id, so every commit of a step precedes its applies (it was commit then apply, shot by shot, in fire // order). The two parts measured: commit-then-apply split alone a1318ee0, with the arrival sort 55ecd858; the old walk at the // h5 head, rebased onto master, still measures 108c93dc, so the rebase and the fire-mode property moved nothing. - private const string TwoAiPinnedDigest = "55ecd858875ca6a5418d6badd40e71d9bbd363ca782843005483a308bcf07d74"; + // Repinned a seventh time, once (h6): Combat is the one owner of the AI's trigger level for an AutoWeapon, and lets go of a held one it + // no longer commands (target gone, untracked, another group the pick, Combat ended), where a held gun fired on until a later + // step selected its group (55ecd858 before). + private const string TwoAiPinnedDigest = "85e85c46cfc66693c819dd294ad02f3cd0eae5486c8f152a97e2e2787f854274"; private const float TwoAiSeconds = 60f; [Fact] From 163bdf9b799d8a27560902055b2ba16771081023 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 13:46:37 +0200 Subject: [PATCH 6/6] Test: Combat releases a held AutoWeapon whose target moves out of reach Co-Authored-By: Claude Sonnet 5.5 --- .../FireControlPerWeaponTests.cs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 6897f0f9a..bbd692cc3 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -1112,6 +1112,29 @@ public sealed class FireControlPerWeaponTests : IDisposable Assert.False(gun.Firing); } + // The target stands but its group is no longer a candidate (it moved out of the gun's reach): the held gun is let go + // the same step, not left to fire at the last aim. Kills: Combat's release sweep after its group pick being lost. + [Fact] + public void CombatReleasesAHeldAutoWeaponWhoseTargetMovesOutOfReach() + { + var r = Build(auto: true); + var gun = (InstantWeapon) r.Gun.Weapon; + var rounds = 0; + gun.OnFire += _ => rounds++; + r.Zone.Agents.Add(new Minion(r.Shooter, FactionDoctrine.Default(r.Shooter.ItemManager.GameplaySettings).Combatant)); + const float dt = 1f / 30f; + for (var i = 0; i < 90; i++) r.Zone.Update(dt); + Assert.True(gun.Firing, "control: the pilot holds the gun while the target is in reach"); + + r.Target.Position = r.Shooter.Position + float3(0, 0, 500); + r.Zone.Update(dt); + var after = rounds; + + Assert.False(gun.Firing); + for (var i = 0; i < 30; i++) r.Zone.Update(dt); + Assert.Equal(after, rounds); + } + // A held AutoWeapon is cooling between its rounds because it is firing: its group is not dropped for cooling down, or // the trigger would be let go and pressed again at step granularity. Kills: releasing a held gun whose cooldown is // running while its target stands.