From e5cfc0d67613ba9d16730fad4dd39adef9f15b3d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 19:25:22 +0200 Subject: [PATCH 1/8] Lock warning: a painted ship owns a typed incoming-lock warning carrying the lock's strength LockWeapon.Execute paints its target with its own Lock (the one value the outgoing indicator also reads); Entity publishes the one-tick inbox as IncomingLocks; the HUD shows one incoming indicator on the strongest warning with the outgoing indicator's curves. Unity HUD code not compiled here; tests not yet run at this commit. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 27 ++ .../ServerShared/Behaviors/LockWeapon.cs | 16 ++ Assets/Scripts/ServerShared/Entity.cs | 19 ++ .../Aetheria.Shared.Tests/LockWarningTests.cs | 258 ++++++++++++++++++ 4 files changed, 320 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/LockWarningTests.cs diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 70db9ae92..2decaca43 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -157,6 +157,8 @@ public class ActionGameManager : MonoBehaviour public Prototype FriendlyTargetIndicator; public PlaceUIElementWorldspace ViewDot; public Prototype LockIndicator; + // Presentation tuning: how far from the ship, toward the locker, the incoming-lock indicator sits. + public float IncomingLockIndicatorDistance = 30; public PlaceUIElementWorldspace[] Crosshairs; public GameObject HitMarker; public float HitMarkerDuration; @@ -195,6 +197,7 @@ public class ActionGameManager : MonoBehaviour private TextMeshProUGUI _faceAimMark; private (HardpointData[] hardpoints, Transform[] barrels, PlaceUIElementWorldspace crosshair)[] _articulationGroups; private (LockWeapon targetLock, PlaceUIElementWorldspace indicator, Rotate spin)[] _lockingIndicators; + private (PlaceUIElementWorldspace indicator, Rotate spin) _incomingLockIndicator; private Dictionary _visibleHostileIndicators = new Dictionary(); private Dictionary _visibleFriendlyIndicators = new Dictionary(); private List _shipSubscriptions = new List(); @@ -999,6 +1002,8 @@ public class ActionGameManager : MonoBehaviour if(_lockingIndicators!=null) foreach(var (_, indicator, _) in _lockingIndicators) indicator.GetComponent().ReturnToPool(); + if(_incomingLockIndicator.indicator != null) _incomingLockIndicator.indicator.GetComponent().ReturnToPool(); + _incomingLockIndicator = default; DisablePlayerInput(); Cursor.lockState = CursorLockMode.None; GameplayUI.gameObject.SetActive(false); @@ -1142,6 +1147,10 @@ public class ActionGameManager : MonoBehaviour var i = LockIndicator.Instantiate(); return (x, i, i.GetComponent()); }).ToArray(); + + var incoming = LockIndicator.Instantiate(); + incoming.gameObject.SetActive(false); + _incomingLockIndicator = (incoming, incoming.GetComponent()); } private void UpdatePlayerPanel() @@ -1449,6 +1458,24 @@ public class ActionGameManager : MonoBehaviour spin.Speed = Settings.GameplaySettings.LockSpinSpeed.Evaluate(targetLock.Lock); } } + + // The strongest lock on the player's ship, read only from IncomingLocks (never another ship's LockWeapon). + // Same curves as the outgoing indicator, driven by the warning's Strength. + var warnings = CurrentEntity.IncomingLocks; + var strongest = -1; + for (var w = 0; w < warnings.Count; w++) + if (strongest < 0 || warnings[w].Strength > warnings[strongest].Strength) strongest = w; + var (incomingIndicator, incomingSpin) = _incomingLockIndicator; + incomingIndicator.gameObject.SetActive(strongest >= 0); + if (strongest >= 0) + { + var warning = warnings[strongest]; + incomingIndicator.Target = CurrentEntity.Position.ToUnity() + + new Vector3(warning.Bearing.x, 0, warning.Bearing.y) * IncomingLockIndicatorDistance; + incomingIndicator.NoiseAmplitude = Settings.GameplaySettings.LockIndicatorNoiseAmplitude * (1 - warning.Strength); + incomingIndicator.NoiseFrequency = Settings.GameplaySettings.LockIndicatorFrequency.Evaluate(warning.Strength); + incomingSpin.Speed = Settings.GameplaySettings.LockSpinSpeed.Evaluate(warning.Strength); + } } } diff --git a/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs index 16f56b72d..c3e234f47 100644 --- a/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs @@ -102,6 +102,7 @@ public class LockWeapon : InstantWeapon { var lerp = 1 - unlerp(0, 90, degrees); _lock = saturate(_lock + pow(lerp, DirectionImpact) * dt * LockSpeed * pow(Entity.EntityInfoGathered[target], SensorImpact)); + target.ReceivePaint(Entity.Position, Item?.Data, Lock); } else _lock = saturate(_lock - dt * Decay); } @@ -110,3 +111,18 @@ public class LockWeapon : InstantWeapon } } +// What a painted ship senses of one beam on it (ruling sensor-stat-set item 4; strength per ruling +// lock-warning-strength). Strength is the emitter's own lock progress at the paint, 0..1, never summed. +public readonly struct LockWarning +{ + public readonly float2 Bearing; + public readonly EquippableItemData EmitterClass; + public readonly float Strength; + + public LockWarning(float2 bearing, EquippableItemData emitterClass, float strength) + { + Bearing = bearing; + EmitterClass = emitterClass; + Strength = strength; + } +} diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 59bc38cc9..0a99ee742 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -173,6 +173,12 @@ public abstract class Entity public Subject HypothermiaDeath = new Subject(); public Subject TargetedBy = new Subject(); public ReactiveProperty TargetedByCount = new ReactiveProperty(0); + // TargetedBy counts selection, which emits nothing; a lock warning comes only from a paint. LockWeapon.Execute + // reports each tick it paints this ship into the inbox; Update publishes the inbox once, so a warning lives + // exactly one tick after its last paint. + private readonly List _paintInbox = new List(); + private readonly List _incomingLocks = new List(); + public IReadOnlyList IncomingLocks => _incomingLocks; public ReactiveProperty CurrentSecurityLevel = new ReactiveProperty(SecurityLevel.Open); public ReadOnlyReactiveProperty PresencePermitted; @@ -783,6 +789,15 @@ public abstract class Entity VisibleEntities.Clear(); VisibleEnemies.Clear(); VisibleFriendlies.Clear(); + _paintInbox.Clear(); + _incomingLocks.Clear(); + } + + // The only caller is LockWeapon.Execute; strength is the emitter's own Lock at that paint. + internal void ReceivePaint(float3 emitterPosition, EquippableItemData emitterClass, float strength) + { + if (!_active) return; + _paintInbox.Add(new LockWarning(normalize((emitterPosition - Position).xz), emitterClass, strength)); } public Entity(ItemManager itemManager, Zone zone, EquippableItem hull, EntitySettings settings) @@ -1378,6 +1393,10 @@ public abstract class Entity TurnRate = 0; if (!_active) return; + _incomingLocks.Clear(); + _incomingLocks.AddRange(_paintInbox); + _paintInbox.Clear(); + var velocityBefore = Velocity; LimitClamp = float2(0, 0); var hullData = ItemManager.GetData(Hull) as HullData; diff --git a/tests/Aetheria.Shared.Tests/LockWarningTests.cs b/tests/Aetheria.Shared.Tests/LockWarningTests.cs new file mode 100644 index 000000000..f3e208f81 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/LockWarningTests.cs @@ -0,0 +1,258 @@ +using System; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using Xunit; +using static CultMath.math; + +// A painted ship owns a typed incoming-lock warning (cut lock-warning; rulings sensor-stat-set item 4 and +// lock-warning-strength). Every fixture drives the production path: a real LockWeapon on a real ship executes, +// the receiver's own Update publishes. The warning's strength is the emitter's own Lock at the paint. +public sealed class LockWarningTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-lockwarn-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + private static readonly int2 HardpointCell = new int2(0, 0); + private CultCache _openCache; + private ItemManager _items; + private Zone _zone; + + public LockWarningTests() + { + Directory.CreateDirectory(_root); + using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + cache.Upsert(new VerseGrammar { Revision = 1 }); + var hullShape = new Shape(3, 3); + foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; + cache.Upsert(new HullData + { + Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 1, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = HardpointCell, Shape = new Shape() } } + }); + cache.Upsert(new GearData + { + Name = "Launcher", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + Behaviors = + { + new LockWeaponData + { + Count = Const(1), Cooldown = Const(1), + LockSpeed = Const(.6f), SensorImpact = Const(1), LockAngle = Const(30), + DirectionImpact = Const(1), Decay = Const(.1f) + } + } + }); + cache.FlushAsync().Wait(); + } + + _openCache = AetheriaStores.Open(Catalog, catalogWritable: true); + var ledger = new ProvenanceLedger(); + ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; + _items = new ItemManager(_openCache, ledger, new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TargetDetectionInfoThreshold = .5f + }, _ => { }); + _zone = new Zone(_items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + } + + public void Dispose() + { + _openCache?.Dispose(); + Directory.Delete(_root, true); + } + + private static PerformanceStat Const(float v) => new PerformanceStat { Min = v, Max = v }; + + private Ship NewShip(float3 position, bool launcher) + { + var hullRef = _items.ItemData.RefOf(_items.ItemData.GetByName("Skiff")); + var ship = new Ship(_items, _zone, new EquippableItem { Data = hullRef, Durability = 1, Lot = 1 }, new EntitySettings()) + { Faction = new Faction { Name = "F" + Guid.NewGuid().ToString("N") } }; + if (launcher) + { + var gearRef = _items.ItemData.RefOf(_items.ItemData.GetByName("Launcher")); + Assert.True(ship.TryEquip(new EquippableItem { Data = gearRef, Durability = 1, Lot = 2 }, HardpointCell)); + } + _zone.Entities.Add(ship); + ship.Activate(); + ship.Position = position; + return ship; + } + + private static LockWeapon LauncherOf(Entity e) => + (LockWeapon) e.Equipment.Single().Behaviors.Single(b => b is LockWeapon); + + // The locker targets `target`, hostile to it, aimed straight at it (or along `aim`), then the launcher ticks. + private static LockWeapon Aim(Ship locker, Ship target, float dt, float3? aim = null) + { + locker.SetIff(target, true); + locker.EntityInfoGathered[target] = 1; + locker.SetTarget(target); + locker.Aim = aim ?? normalize(target.Position - locker.Position); + var launcher = LauncherOf(locker); + launcher.Execute(dt); + return launcher; + } + + [Fact] + public void APaintedShipLearnsBearingClassAndStrength() + { + var locker = NewShip(float3(0, 0, 0), launcher: true); + var b = NewShip(float3(3, 0, 4), launcher: false); + + var launcher = Aim(locker, b, .5f); + b.Update(.01f); + + var warning = Assert.Single(b.IncomingLocks); + Assert.Equal(-.6f, warning.Bearing.x, 4); + Assert.Equal(-.8f, warning.Bearing.y, 4); + Assert.Same(locker.Equipment.Single().Data, warning.EmitterClass); + Assert.Equal(.3f, launcher.Lock, 4); // fixture: 0.6 speed * 0.5 s on a dead-on, fully sensed target + Assert.Equal(.3f, warning.Strength, 4); + + // The strength follows the emitter's lock as it grows: a stale or scaled copy would not. + Aim(locker, b, .5f); + b.Update(.01f); + Assert.Equal(.6f, launcher.Lock, 4); + Assert.Equal(.6f, Assert.Single(b.IncomingLocks).Strength, 4); + } + + [Fact] + public void AStrengthAtTheCeilingIsOne() + { + var locker = NewShip(float3(0, 0, 0), launcher: true); + var b = NewShip(float3(0, 0, 9), launcher: false); + + Aim(locker, b, 5f); // saturates the lock + b.Update(.01f); + + Assert.Equal(1f, Assert.Single(b.IncomingLocks).Strength, 4); + } + + [Fact] + public void APaintFromALockerItCannotSeeStillWarns() + { + var locker = NewShip(float3(0, 0, 0), launcher: true); + var b = NewShip(float3(0, 0, 9), launcher: false); + b.EntityInfoGathered[locker] = 0; + Assert.DoesNotContain(locker, b.VisibleEnemies); + + Aim(locker, b, .5f); + b.Update(.01f); + + Assert.Single(b.IncomingLocks); + Assert.DoesNotContain(locker, b.VisibleEnemies); + } + + [Fact] + public void TargetedIsNotPainted() + { + var locker = NewShip(float3(0, 0, 0), launcher: true); + var b = NewShip(float3(0, 0, 9), launcher: false); + var c = NewShip(float3(9, 0, 0), launcher: false); + + // B is targeted but the launcher is aimed at C, far outside LockAngle of B. + locker.SetIff(b, true); + locker.SetIff(c, true); + locker.EntityInfoGathered[b] = 1; + locker.EntityInfoGathered[c] = 1; + locker.SetTarget(b); + locker.Aim = float3(1, 0, 0); + LauncherOf(locker).Execute(.5f); + b.Update(.01f); + c.Update(.01f); + + Assert.Empty(b.IncomingLocks); + Assert.Empty(c.IncomingLocks); // C is aimed at but not the target: nothing paints it either + } + + [Fact] + public void AWarningEndsOneTickAfterItsPaint() + { + var locker = NewShip(float3(0, 0, 0), launcher: true); + var b = NewShip(float3(0, 0, 9), launcher: false); + + var launcher = Aim(locker, b, .5f); + b.Update(.01f); + Assert.Single(b.IncomingLocks); + + // No emission this tick (the launcher item is offline): the warning is gone after B's next Update. + b.Update(.01f); + Assert.Empty(b.IncomingLocks); + + // The locker turns away: the lock decays but is still above zero, and nothing is painted. + Aim(locker, b, .5f); + b.Update(.01f); + Assert.Single(b.IncomingLocks); + locker.Aim = float3(0, 0, -1); + launcher.Execute(.5f); + Assert.True(launcher.Lock > 0f, "fixture: the lock decays, it does not vanish"); + b.Update(.01f); + Assert.Empty(b.IncomingLocks); + } + + [Fact] + public void ARetargetedLockerNoLongerWarnsItsOldTarget() + { + var locker = NewShip(float3(0, 0, 0), launcher: true); + var b = NewShip(float3(0, 0, 9), launcher: false); + var c = NewShip(float3(9, 0, 0), launcher: false); + + var launcher = Aim(locker, b, .5f); + b.Update(.01f); + Assert.Single(b.IncomingLocks); + + Aim(locker, c, .5f); // retarget: aim at C + b.Update(.01f); + c.Update(.01f); + + Assert.Empty(b.IncomingLocks); + Assert.Equal(.3f, Assert.Single(c.IncomingLocks).Strength, 4); // the lock restarted on C + Assert.Equal(.3f, launcher.Lock, 4); + } + + [Fact] + public void EachPainterIsItsOwnWarning() + { + var one = NewShip(float3(0, 0, 0), launcher: true); + var two = NewShip(float3(10, 0, 10), launcher: true); + var b = NewShip(float3(0, 0, 10), launcher: false); + + Aim(one, b, .5f); // 0.3 + Aim(two, b, .25f); // 0.15 + b.Update(.01f); + + Assert.Equal(2, b.IncomingLocks.Count); + var strengths = b.IncomingLocks.Select(w => w.Strength).OrderBy(s => s).ToArray(); + Assert.Equal(.15f, strengths[0], 4); + Assert.Equal(.3f, strengths[1], 4); // never summed to 0.45 + Assert.Equal(2, b.IncomingLocks.Select(w => (w.Bearing.x, w.Bearing.y)).Distinct().Count()); + } + + [Fact] + public void AnInactiveShipHoldsNoWarnings() + { + var locker = NewShip(float3(0, 0, 0), launcher: true); + var b = NewShip(float3(0, 0, 9), launcher: false); + Aim(locker, b, .5f); + b.Update(.01f); + Assert.Single(b.IncomingLocks); + + b.Deactivate(); + Assert.Empty(b.IncomingLocks); + + Aim(locker, b, .5f); // painted while docked or dead + b.Update(.01f); + Assert.Empty(b.IncomingLocks); + + b.Activate(); + b.Update(.01f); + Assert.Empty(b.IncomingLocks); // the paint received while inactive was never stored + } +} From 033d37eda070574824aa40010407e1ea0cfdb329 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 19:41:04 +0200 Subject: [PATCH 2/8] LockWarningTests: build the launcher as the existing lock fixtures do (a plain gear, the LockWeapon beside it) Co-Authored-By: Claude Opus 5.5 --- .../Aetheria.Shared.Tests/LockWarningTests.cs | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/LockWarningTests.cs b/tests/Aetheria.Shared.Tests/LockWarningTests.cs index f3e208f81..6ec3f224a 100644 --- a/tests/Aetheria.Shared.Tests/LockWarningTests.cs +++ b/tests/Aetheria.Shared.Tests/LockWarningTests.cs @@ -34,15 +34,7 @@ public sealed class LockWarningTests : IDisposable cache.Upsert(new GearData { Name = "Launcher", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, - Behaviors = - { - new LockWeaponData - { - Count = Const(1), Cooldown = Const(1), - LockSpeed = Const(.6f), SensorImpact = Const(1), LockAngle = Const(30), - DirectionImpact = Const(1), Decay = Const(.1f) - } - } + Behaviors = { new InstantWeaponData { Count = Const(1), Cooldown = Const(1) } } }); cache.FlushAsync().Wait(); } @@ -78,6 +70,11 @@ public sealed class LockWarningTests : IDisposable { var gearRef = _items.ItemData.RefOf(_items.ItemData.GetByName("Launcher")); Assert.True(ship.TryEquip(new EquippableItem { Data = gearRef, Durability = 1, Lot = 2 }, HardpointCell)); + _launchers[ship] = new LockWeapon(new LockWeaponData + { + LockSpeed = Const(.6f), SensorImpact = Const(1), LockAngle = Const(30), + DirectionImpact = Const(1), Decay = Const(.1f) + }, ship.Equipment.Single()); } _zone.Entities.Add(ship); ship.Activate(); @@ -85,11 +82,11 @@ public sealed class LockWarningTests : IDisposable return ship; } - private static LockWeapon LauncherOf(Entity e) => - (LockWeapon) e.Equipment.Single().Behaviors.Single(b => b is LockWeapon); + private readonly System.Collections.Generic.Dictionary _launchers = new System.Collections.Generic.Dictionary(); + private LockWeapon LauncherOf(Entity e) => _launchers[e]; // The locker targets `target`, hostile to it, aimed straight at it (or along `aim`), then the launcher ticks. - private static LockWeapon Aim(Ship locker, Ship target, float dt, float3? aim = null) + private LockWeapon Aim(Ship locker, Ship target, float dt, float3? aim = null) { locker.SetIff(target, true); locker.EntityInfoGathered[target] = 1; From 6fff037ac7fa76a43035b4065455512c8c15229a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 19:46:48 +0200 Subject: [PATCH 3/8] LockWarningTests: find the launcher gear by its weapon behaviour Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/LockWarningTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/LockWarningTests.cs b/tests/Aetheria.Shared.Tests/LockWarningTests.cs index 6ec3f224a..e6555c37e 100644 --- a/tests/Aetheria.Shared.Tests/LockWarningTests.cs +++ b/tests/Aetheria.Shared.Tests/LockWarningTests.cs @@ -74,7 +74,7 @@ public sealed class LockWarningTests : IDisposable { LockSpeed = Const(.6f), SensorImpact = Const(1), LockAngle = Const(30), DirectionImpact = Const(1), Decay = Const(.1f) - }, ship.Equipment.Single()); + }, GearOf(ship)); } _zone.Entities.Add(ship); ship.Activate(); @@ -83,6 +83,7 @@ public sealed class LockWarningTests : IDisposable } private readonly System.Collections.Generic.Dictionary _launchers = new System.Collections.Generic.Dictionary(); + private static EquippedItem GearOf(Entity e) => e.Equipment.Single(i => i.Behaviors.Any(b => b is Weapon)); private LockWeapon LauncherOf(Entity e) => _launchers[e]; // The locker targets `target`, hostile to it, aimed straight at it (or along `aim`), then the launcher ticks. @@ -109,7 +110,7 @@ public sealed class LockWarningTests : IDisposable var warning = Assert.Single(b.IncomingLocks); Assert.Equal(-.6f, warning.Bearing.x, 4); Assert.Equal(-.8f, warning.Bearing.y, 4); - Assert.Same(locker.Equipment.Single().Data, warning.EmitterClass); + Assert.Same(GearOf(locker).Data, warning.EmitterClass); Assert.Equal(.3f, launcher.Lock, 4); // fixture: 0.6 speed * 0.5 s on a dead-on, fully sensed target Assert.Equal(.3f, warning.Strength, 4); From 90f1775cb98ed290ad1893ed03f5103c93b9f3c8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 19:49:56 +0200 Subject: [PATCH 4/8] LockWarningTests: a paint waiting in the inbox does not outlive a deactivation Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/LockWarningTests.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/LockWarningTests.cs b/tests/Aetheria.Shared.Tests/LockWarningTests.cs index e6555c37e..c12b724ae 100644 --- a/tests/Aetheria.Shared.Tests/LockWarningTests.cs +++ b/tests/Aetheria.Shared.Tests/LockWarningTests.cs @@ -252,5 +252,12 @@ public sealed class LockWarningTests : IDisposable b.Activate(); b.Update(.01f); Assert.Empty(b.IncomingLocks); // the paint received while inactive was never stored + + // A paint still waiting in the inbox when the ship deactivates does not survive to the next activation. + Aim(locker, b, .5f); + b.Deactivate(); + b.Activate(); + b.Update(.01f); + Assert.Empty(b.IncomingLocks); } } From 1c5c60205467978e05564152197041d657659266 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 23:29:27 +0200 Subject: [PATCH 5/8] Lock warning fix 1: publish paints in a zone phase, one lock-indicator curve, strength and progress-term pins - Zone.Update publishes every entity's paints before any entity updates (Entity.PublishPaints), so a broken lock clears on the same tick whatever the entity order. - ActionGameManager.ApplyLockProgress is the one lock-indicator curve, called by the outgoing and the incoming indicator. - LockWarningTests drive the real LockWeapon through Zone.Update; pins on strength across 0.15..1.0, the direction and sensor progress terms, and both entity orders. Not yet run: this commit's tests have not been built. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 16 +- Assets/Scripts/ServerShared/Entity.cs | 14 +- Assets/Scripts/ServerShared/Zone.cs | 4 +- .../Aetheria.Shared.Tests/LockWarningTests.cs | 278 +++++++++++------- 4 files changed, 201 insertions(+), 111 deletions(-) diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 2decaca43..3e9004a84 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -1453,9 +1453,7 @@ public class ActionGameManager : MonoBehaviour if(showLockingIndicator) { indicator.Target = CurrentEntity.Target.Value.Entity.Position.ToUnity(); - indicator.NoiseAmplitude = Settings.GameplaySettings.LockIndicatorNoiseAmplitude * (1 - targetLock.Lock); - indicator.NoiseFrequency = Settings.GameplaySettings.LockIndicatorFrequency.Evaluate(targetLock.Lock); - spin.Speed = Settings.GameplaySettings.LockSpinSpeed.Evaluate(targetLock.Lock); + ApplyLockProgress(indicator, spin, targetLock.Lock); } } @@ -1472,11 +1470,17 @@ public class ActionGameManager : MonoBehaviour var warning = warnings[strongest]; incomingIndicator.Target = CurrentEntity.Position.ToUnity() + new Vector3(warning.Bearing.x, 0, warning.Bearing.y) * IncomingLockIndicatorDistance; - incomingIndicator.NoiseAmplitude = Settings.GameplaySettings.LockIndicatorNoiseAmplitude * (1 - warning.Strength); - incomingIndicator.NoiseFrequency = Settings.GameplaySettings.LockIndicatorFrequency.Evaluate(warning.Strength); - incomingSpin.Speed = Settings.GameplaySettings.LockSpinSpeed.Evaluate(warning.Strength); + ApplyLockProgress(incomingIndicator, incomingSpin, warning.Strength); } } + + // The one lock-indicator curve: outgoing (the player's own lock) and incoming (the warning's strength) both tighten through it. + private void ApplyLockProgress(PlaceUIElementWorldspace indicator, Rotate spin, float progress) + { + indicator.NoiseAmplitude = Settings.GameplaySettings.LockIndicatorNoiseAmplitude * (1 - progress); + indicator.NoiseFrequency = Settings.GameplaySettings.LockIndicatorFrequency.Evaluate(progress); + spin.Speed = Settings.GameplaySettings.LockSpinSpeed.Evaluate(progress); + } } public abstract class DragObject{} diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 0a99ee742..97dd24a8a 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -793,6 +793,16 @@ public abstract class Entity _incomingLocks.Clear(); } + // Zone.Update runs this on every entity before any entity updates, so a paint made during tick N is published + // at the start of tick N+1 whatever order the emitter and this ship update in; a paint that stops is gone + // one tick later for every ordering. + internal void PublishPaints() + { + _incomingLocks.Clear(); + _incomingLocks.AddRange(_paintInbox); + _paintInbox.Clear(); + } + // The only caller is LockWeapon.Execute; strength is the emitter's own Lock at that paint. internal void ReceivePaint(float3 emitterPosition, EquippableItemData emitterClass, float strength) { @@ -1393,10 +1403,6 @@ public abstract class Entity TurnRate = 0; if (!_active) return; - _incomingLocks.Clear(); - _incomingLocks.AddRange(_paintInbox); - _paintInbox.Clear(); - var velocityBefore = Velocity; LimitClamp = float2(0, 0); var hullData = ItemManager.GetData(Hull) as HullData; diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 0aa822775..ebeee66b8 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -226,7 +226,9 @@ public class Zone foreach(var agent in Agents) agent.Update(deltaTime); - foreach (var entity in Entities.ToArray()) entity.Update(deltaTime); + var entities = Entities.ToArray(); + foreach (var entity in entities) entity.PublishPaints(); + foreach (var entity in entities) entity.Update(deltaTime); StepMines(deltaTime); StepClouds(deltaTime); diff --git a/tests/Aetheria.Shared.Tests/LockWarningTests.cs b/tests/Aetheria.Shared.Tests/LockWarningTests.cs index c12b724ae..50b8a49e4 100644 --- a/tests/Aetheria.Shared.Tests/LockWarningTests.cs +++ b/tests/Aetheria.Shared.Tests/LockWarningTests.cs @@ -7,8 +7,9 @@ using Xunit; using static CultMath.math; // A painted ship owns a typed incoming-lock warning (cut lock-warning; rulings sensor-stat-set item 4 and -// lock-warning-strength). Every fixture drives the production path: a real LockWeapon on a real ship executes, -// the receiver's own Update publishes. The warning's strength is the emitter's own Lock at the paint. +// lock-warning-strength). Every fixture drives the production path: a real LockWeapon in a real ship's gear +// executes inside Zone.Update, and the zone publishes every ship's paints before any ship updates. A paint made +// during tick N is therefore what IncomingLocks holds after tick N+1, whatever order the ships sit in the zone. public sealed class LockWarningTests : IDisposable { private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-lockwarn-" + Guid.NewGuid().ToString("N")); @@ -28,27 +29,33 @@ public sealed class LockWarningTests : IDisposable foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; cache.Upsert(new HullData { - Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 1, + Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 1000, Mass = 1000, Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = HardpointCell, Shape = new Shape() } } }); - cache.Upsert(new GearData - { - Name = "Launcher", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, - Behaviors = { new InstantWeaponData { Count = Const(1), Cooldown = Const(1) } } - }); + cache.Upsert(Launcher("Launcher", lockSpeed: .6f, sensorImpact: 1, lockAngle: 30, directionImpact: 1)); + cache.Upsert(Launcher("SkewLauncher", lockSpeed: .8f, sensorImpact: 3, lockAngle: 45, directionImpact: 2)); cache.FlushAsync().Wait(); } _openCache = AetheriaStores.Open(Catalog, catalogWritable: true); var ledger = new ProvenanceLedger(); - ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; - ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; + for (var lot = 1; lot < 12; lot++) ledger.Lots[lot] = new Lot { Origin = new Attributed(), Quality = 1 }; _items = new ItemManager(_openCache, ledger, new GameplaySettings { DefaultEntitySettings = new EntitySettings(), Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, QualityPriceModifier = new ExponentialLerp(), - TargetDetectionInfoThreshold = .5f + TargetDetectionInfoThreshold = .1f, + TargetArmorInfoThreshold = .2f, + TargetGearInfoThreshold = .8f, + FiringArc = 170, + CommitHorizon = .5f, + SchematicCellSize = 1f, + UnaidedAccuracy = .05f, + UnaidedTracking = 10f, + UnaidedPrecision = 2f, + AgentMinHitProbability = 0f, + BeamResolveInterval = .1f }, _ => { }); _zone = new Zone(_items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); } @@ -61,88 +68,150 @@ public sealed class LockWarningTests : IDisposable private static PerformanceStat Const(float v) => new PerformanceStat { Min = v, Max = v }; - private Ship NewShip(float3 position, bool launcher) + private static GearData Launcher(string name, float lockSpeed, float sensorImpact, float lockAngle, float directionImpact) => new GearData + { + Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = + { + new LockWeaponData + { + Damage = Const(0), Range = Const(1000), MinRange = Const(0), Velocity = Const(0), + Spread = Const(0), DamageSpread = Const(0), Penetration = Const(0), Count = Const(1), + BurstTime = Const(0), Cooldown = Const(1000), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }, + LockSpeed = Const(lockSpeed), SensorImpact = Const(sensorImpact), LockAngle = Const(lockAngle), + DirectionImpact = Const(directionImpact), Decay = Const(.1f) + } + } + }; + + private int _nextLot = 2; + + private Ship NewShip(float3 position, string launcher = null) { var hullRef = _items.ItemData.RefOf(_items.ItemData.GetByName("Skiff")); - var ship = new Ship(_items, _zone, new EquippableItem { Data = hullRef, Durability = 1, Lot = 1 }, new EntitySettings()) + var ship = new Ship(_items, _zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }, new EntitySettings()) { Faction = new Faction { Name = "F" + Guid.NewGuid().ToString("N") } }; - if (launcher) + if (launcher != null) { - var gearRef = _items.ItemData.RefOf(_items.ItemData.GetByName("Launcher")); - Assert.True(ship.TryEquip(new EquippableItem { Data = gearRef, Durability = 1, Lot = 2 }, HardpointCell)); - _launchers[ship] = new LockWeapon(new LockWeaponData - { - LockSpeed = Const(.6f), SensorImpact = Const(1), LockAngle = Const(30), - DirectionImpact = Const(1), Decay = Const(.1f) - }, GearOf(ship)); + var gearRef = _items.ItemData.RefOf(_items.ItemData.GetByName(launcher)); + Assert.True(ship.TryEquip(new EquippableItem { Data = gearRef, Durability = 1, Lot = _nextLot++ }, HardpointCell)); } _zone.Entities.Add(ship); ship.Activate(); ship.Position = position; + _zone.Update(0f); // brings the gear online return ship; } - private readonly System.Collections.Generic.Dictionary _launchers = new System.Collections.Generic.Dictionary(); - private static EquippedItem GearOf(Entity e) => e.Equipment.Single(i => i.Behaviors.Any(b => b is Weapon)); - private LockWeapon LauncherOf(Entity e) => _launchers[e]; + private static LockWeapon LauncherOf(Entity e) => e.GetBehavior(); - // The locker targets `target`, hostile to it, aimed straight at it (or along `aim`), then the launcher ticks. - private LockWeapon Aim(Ship locker, Ship target, float dt, float3? aim = null) + private static EquippedItem GearOf(Entity e) => e.Equipment.Single(i => i.Behaviors.Any(b => b is LockWeapon)); + + // The locker targets `target`, hostile to it, aimed straight at it (or along `aim`). The next zone tick paints. + private static void Aim(Ship locker, Ship target, float3? aim = null, float info = 1f) { locker.SetIff(target, true); - locker.EntityInfoGathered[target] = 1; + locker.EntityInfoGathered[target] = info; locker.SetTarget(target); locker.Aim = aim ?? normalize(target.Position - locker.Position); - var launcher = LauncherOf(locker); - launcher.Execute(dt); - return launcher; } + private void Tick(float dt) => _zone.Update(dt); + [Fact] public void APaintedShipLearnsBearingClassAndStrength() { - var locker = NewShip(float3(0, 0, 0), launcher: true); - var b = NewShip(float3(3, 0, 4), launcher: false); + var locker = NewShip(float3(0, 0, 0), "Launcher"); + var b = NewShip(float3(3, 0, 4)); + var launcher = LauncherOf(locker); - var launcher = Aim(locker, b, .5f); - b.Update(.01f); + Aim(locker, b); + Tick(.5f); + Assert.Empty(b.IncomingLocks); // published at the start of the next tick + Assert.Equal(.3f, launcher.Lock, 4); // fixture: 0.6 speed * 0.5 s on a dead-on, fully sensed target + Tick(.5f); var warning = Assert.Single(b.IncomingLocks); Assert.Equal(-.6f, warning.Bearing.x, 4); Assert.Equal(-.8f, warning.Bearing.y, 4); Assert.Same(GearOf(locker).Data, warning.EmitterClass); - Assert.Equal(.3f, launcher.Lock, 4); // fixture: 0.6 speed * 0.5 s on a dead-on, fully sensed target Assert.Equal(.3f, warning.Strength, 4); // The strength follows the emitter's lock as it grows: a stale or scaled copy would not. - Aim(locker, b, .5f); - b.Update(.01f); Assert.Equal(.6f, launcher.Lock, 4); + Tick(0f); Assert.Equal(.6f, Assert.Single(b.IncomingLocks).Strength, 4); } - [Fact] - public void AStrengthAtTheCeilingIsOne() + // The whole range of strength is pinned, not its middle: lock = 0.6 * dt on a dead-on, fully sensed target. + [Theory] + [InlineData(.25f, .15f)] + [InlineData(1f, .6f)] + [InlineData(1.25f, .75f)] + [InlineData(1.5f, .9f)] + [InlineData(1.55f, .93f)] + [InlineData(1.6f, .96f)] + [InlineData(1.65f, .99f)] + [InlineData(5f, 1f)] + public void StrengthIsTheLockAcrossItsWholeRange(float dt, float expectedLock) { - var locker = NewShip(float3(0, 0, 0), launcher: true); - var b = NewShip(float3(0, 0, 9), launcher: false); + var locker = NewShip(float3(0, 0, 0), "Launcher"); + var b = NewShip(float3(0, 0, 9)); - Aim(locker, b, 5f); // saturates the lock - b.Update(.01f); + Aim(locker, b); + Tick(dt); + Assert.Equal(expectedLock, LauncherOf(locker).Lock, 4); + Tick(0f); - Assert.Equal(1f, Assert.Single(b.IncomingLocks).Strength, 4); + var strength = Assert.Single(b.IncomingLocks).Strength; + Assert.Equal(expectedLock, strength, 4); + Assert.Equal(LauncherOf(locker).Lock, strength, 4); + } + + // The lock's progress terms: direction (lerp ^ DirectionImpact) and sensor info (info ^ SensorImpact), each + // with an exponent other than 1, aimed off-axis at a partly sensed target. Lock after dt = 1: + // 0.8 * (1 - 30/90)^2 * 0.8^3 = 0.18204. + [Fact] + public void TheLockGrowsByDirectionAndSensorTermsNotJustSpeed() + { + var locker = NewShip(float3(0, 0, 0), "SkewLauncher"); + var b = NewShip(float3(0, 0, 9)); + var thirtyOff = float3(sin(30f * .0174533f), 0, cos(30f * .0174533f)); + + Aim(locker, b, thirtyOff, info: .8f); + Tick(1f); + Assert.Equal(.18204f, LauncherOf(locker).Lock, 4); + Tick(0f); + + Assert.Equal(.18204f, Assert.Single(b.IncomingLocks).Strength, 4); + } + + // Dead on, the direction term is 1 and the sensor term alone scales the lock: 0.8 * 0.5^3 = 0.1. + [Fact] + public void TheSensorTermAloneScalesADeadOnLock() + { + var locker = NewShip(float3(0, 0, 0), "SkewLauncher"); + var b = NewShip(float3(0, 0, 9)); + + Aim(locker, b, info: .5f); + Tick(1f); + + Assert.Equal(.1f, LauncherOf(locker).Lock, 4); } [Fact] public void APaintFromALockerItCannotSeeStillWarns() { - var locker = NewShip(float3(0, 0, 0), launcher: true); - var b = NewShip(float3(0, 0, 9), launcher: false); + var locker = NewShip(float3(0, 0, 0), "Launcher"); + var b = NewShip(float3(0, 0, 9)); b.EntityInfoGathered[locker] = 0; Assert.DoesNotContain(locker, b.VisibleEnemies); - Aim(locker, b, .5f); - b.Update(.01f); + Aim(locker, b); + Tick(.5f); + Tick(0f); Assert.Single(b.IncomingLocks); Assert.DoesNotContain(locker, b.VisibleEnemies); @@ -151,9 +220,9 @@ public sealed class LockWarningTests : IDisposable [Fact] public void TargetedIsNotPainted() { - var locker = NewShip(float3(0, 0, 0), launcher: true); - var b = NewShip(float3(0, 0, 9), launcher: false); - var c = NewShip(float3(9, 0, 0), launcher: false); + var locker = NewShip(float3(0, 0, 0), "Launcher"); + var b = NewShip(float3(0, 0, 9)); + var c = NewShip(float3(9, 0, 0)); // B is targeted but the launcher is aimed at C, far outside LockAngle of B. locker.SetIff(b, true); @@ -162,102 +231,111 @@ public sealed class LockWarningTests : IDisposable locker.EntityInfoGathered[c] = 1; locker.SetTarget(b); locker.Aim = float3(1, 0, 0); - LauncherOf(locker).Execute(.5f); - b.Update(.01f); - c.Update(.01f); + Tick(.5f); + Tick(0f); Assert.Empty(b.IncomingLocks); Assert.Empty(c.IncomingLocks); // C is aimed at but not the target: nothing paints it either } - [Fact] - public void AWarningEndsOneTickAfterItsPaint() + // A broken lock stops warning on the same tick whichever of the two ships the zone updates first. + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AWarningEndsTwoTicksAfterItsLastPaintInEveryEntityOrder(bool receiverFirst) { - var locker = NewShip(float3(0, 0, 0), launcher: true); - var b = NewShip(float3(0, 0, 9), launcher: false); + Ship locker, b; + if (receiverFirst) + { + b = NewShip(float3(0, 0, 9)); + locker = NewShip(float3(0, 0, 0), "Launcher"); + } + else + { + locker = NewShip(float3(0, 0, 0), "Launcher"); + b = NewShip(float3(0, 0, 9)); + } + Assert.Equal(receiverFirst ? (Entity) b : locker, _zone.Entities[0]); - var launcher = Aim(locker, b, .5f); - b.Update(.01f); - Assert.Single(b.IncomingLocks); - - // No emission this tick (the launcher item is offline): the warning is gone after B's next Update. - b.Update(.01f); + Aim(locker, b); + Tick(.5f); // tick 1: painted Assert.Empty(b.IncomingLocks); - - // The locker turns away: the lock decays but is still above zero, and nothing is painted. - Aim(locker, b, .5f); - b.Update(.01f); + Tick(.5f); // tick 2: tick 1's paint published, painted again Assert.Single(b.IncomingLocks); - locker.Aim = float3(0, 0, -1); - launcher.Execute(.5f); - Assert.True(launcher.Lock > 0f, "fixture: the lock decays, it does not vanish"); - b.Update(.01f); + + locker.Aim = float3(0, 0, -1); // the lock breaks: decays, paints nothing + Tick(.5f); // tick 3: tick 2's paint published, nothing painted + Assert.Single(b.IncomingLocks); + Assert.True(LauncherOf(locker).Lock > 0f, "fixture: the lock decays, it does not vanish"); + Tick(.5f); // tick 4: nothing to publish Assert.Empty(b.IncomingLocks); } [Fact] public void ARetargetedLockerNoLongerWarnsItsOldTarget() { - var locker = NewShip(float3(0, 0, 0), launcher: true); - var b = NewShip(float3(0, 0, 9), launcher: false); - var c = NewShip(float3(9, 0, 0), launcher: false); + var locker = NewShip(float3(0, 0, 0), "Launcher"); + var b = NewShip(float3(0, 0, 9)); + var c = NewShip(float3(9, 0, 0)); + var launcher = LauncherOf(locker); - var launcher = Aim(locker, b, .5f); - b.Update(.01f); + Aim(locker, b); + Tick(.5f); + Tick(.5f); Assert.Single(b.IncomingLocks); - Aim(locker, c, .5f); // retarget: aim at C - b.Update(.01f); - c.Update(.01f); + Aim(locker, c); // retarget: aim at C + Tick(.5f); // the lock restarts on C: 0.3 + Tick(0f); // tick after the retarget: B has nothing newer to publish Assert.Empty(b.IncomingLocks); - Assert.Equal(.3f, Assert.Single(c.IncomingLocks).Strength, 4); // the lock restarted on C + Assert.Equal(.3f, Assert.Single(c.IncomingLocks).Strength, 4); Assert.Equal(.3f, launcher.Lock, 4); } [Fact] public void EachPainterIsItsOwnWarning() { - var one = NewShip(float3(0, 0, 0), launcher: true); - var two = NewShip(float3(10, 0, 10), launcher: true); - var b = NewShip(float3(0, 0, 10), launcher: false); + var one = NewShip(float3(0, 0, 0), "Launcher"); + var two = NewShip(float3(10, 0, 10), "Launcher"); + var b = NewShip(float3(0, 0, 10)); - Aim(one, b, .5f); // 0.3 - Aim(two, b, .25f); // 0.15 - b.Update(.01f); + Aim(one, b); // dead on: 0.6 * 0.5 = 0.3 + var twentyOff = float3(-cos(20f * .0174533f), 0, sin(20f * .0174533f)); + Aim(two, b, twentyOff); // 20 degrees off: 0.3 * (1 - 20/90) = 0.23333 + Tick(.5f); + Tick(0f); Assert.Equal(2, b.IncomingLocks.Count); var strengths = b.IncomingLocks.Select(w => w.Strength).OrderBy(s => s).ToArray(); - Assert.Equal(.15f, strengths[0], 4); - Assert.Equal(.3f, strengths[1], 4); // never summed to 0.45 + Assert.Equal(.23333f, strengths[0], 4); + Assert.Equal(.3f, strengths[1], 4); // never summed Assert.Equal(2, b.IncomingLocks.Select(w => (w.Bearing.x, w.Bearing.y)).Distinct().Count()); } [Fact] public void AnInactiveShipHoldsNoWarnings() { - var locker = NewShip(float3(0, 0, 0), launcher: true); - var b = NewShip(float3(0, 0, 9), launcher: false); - Aim(locker, b, .5f); - b.Update(.01f); + var locker = NewShip(float3(0, 0, 0), "Launcher"); + var b = NewShip(float3(0, 0, 9)); + Aim(locker, b); + Tick(.5f); + Tick(0f); Assert.Single(b.IncomingLocks); b.Deactivate(); Assert.Empty(b.IncomingLocks); - Aim(locker, b, .5f); // painted while docked or dead - b.Update(.01f); - Assert.Empty(b.IncomingLocks); - + Tick(.5f); // painted while docked or dead b.Activate(); - b.Update(.01f); + Tick(0f); Assert.Empty(b.IncomingLocks); // the paint received while inactive was never stored // A paint still waiting in the inbox when the ship deactivates does not survive to the next activation. - Aim(locker, b, .5f); + Tick(.5f); b.Deactivate(); b.Activate(); - b.Update(.01f); + Tick(0f); Assert.Empty(b.IncomingLocks); } } From 3022741745165d80bab9cc343d4beb2a132e8c3f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 23:41:54 +0200 Subject: [PATCH 6/8] LockWarningTests: author the launcher as LauncherData (the catalog round-trips it); last commit's tests did not run --- tests/Aetheria.Shared.Tests/LockWarningTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/LockWarningTests.cs b/tests/Aetheria.Shared.Tests/LockWarningTests.cs index 50b8a49e4..7fc914266 100644 --- a/tests/Aetheria.Shared.Tests/LockWarningTests.cs +++ b/tests/Aetheria.Shared.Tests/LockWarningTests.cs @@ -74,7 +74,7 @@ public sealed class LockWarningTests : IDisposable MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, Behaviors = { - new LockWeaponData + new LauncherData // the production launcher type; the catalog round-trips it (a bare LockWeaponData is not a stored union member) { Damage = Const(0), Range = Const(1000), MinRange = Const(0), Velocity = Const(0), Spread = Const(0), DamageSpread = Const(0), Penetration = Const(0), Count = Const(1), From a403b44ab52a4d2355d7906ad482833a5e80041d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 23:43:03 +0200 Subject: [PATCH 7/8] LockWarningTests: a full lock fires and spends the launcher's lock; read the emitter's own Lock only below it --- tests/Aetheria.Shared.Tests/LockWarningTests.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/LockWarningTests.cs b/tests/Aetheria.Shared.Tests/LockWarningTests.cs index 7fc914266..6ad6bdc58 100644 --- a/tests/Aetheria.Shared.Tests/LockWarningTests.cs +++ b/tests/Aetheria.Shared.Tests/LockWarningTests.cs @@ -162,12 +162,11 @@ public sealed class LockWarningTests : IDisposable Aim(locker, b); Tick(dt); - Assert.Equal(expectedLock, LauncherOf(locker).Lock, 4); + // A full lock fires the launcher and spends the lock, so the emitter's own Lock is read only below it. + if (expectedLock < 1f) Assert.Equal(expectedLock, LauncherOf(locker).Lock, 4); Tick(0f); - var strength = Assert.Single(b.IncomingLocks).Strength; - Assert.Equal(expectedLock, strength, 4); - Assert.Equal(LauncherOf(locker).Lock, strength, 4); + Assert.Equal(expectedLock, Assert.Single(b.IncomingLocks).Strength, 4); } // The lock's progress terms: direction (lerp ^ DirectionImpact) and sensor info (info ^ SensorImpact), each From efaa8f5b540d02852a5d5f8cdc82a8261e6df597 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 23:45:21 +0200 Subject: [PATCH 8/8] LockWarningTests: pin the full-lock strength through the launcher's own Execute (a zone tick resolves the shot a full lock fires) --- .../Aetheria.Shared.Tests/LockWarningTests.cs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/LockWarningTests.cs b/tests/Aetheria.Shared.Tests/LockWarningTests.cs index 6ad6bdc58..bf05566ac 100644 --- a/tests/Aetheria.Shared.Tests/LockWarningTests.cs +++ b/tests/Aetheria.Shared.Tests/LockWarningTests.cs @@ -154,7 +154,6 @@ public sealed class LockWarningTests : IDisposable [InlineData(1.55f, .93f)] [InlineData(1.6f, .96f)] [InlineData(1.65f, .99f)] - [InlineData(5f, 1f)] public void StrengthIsTheLockAcrossItsWholeRange(float dt, float expectedLock) { var locker = NewShip(float3(0, 0, 0), "Launcher"); @@ -162,13 +161,27 @@ public sealed class LockWarningTests : IDisposable Aim(locker, b); Tick(dt); - // A full lock fires the launcher and spends the lock, so the emitter's own Lock is read only below it. - if (expectedLock < 1f) Assert.Equal(expectedLock, LauncherOf(locker).Lock, 4); + Assert.Equal(expectedLock, LauncherOf(locker).Lock, 4); Tick(0f); Assert.Equal(expectedLock, Assert.Single(b.IncomingLocks).Strength, 4); } + // A saturated lock is a strength of exactly 1: the launcher's own Execute outside the zone tick, so that the + // zone's fire resolution does not run on the shot a full lock fires. + [Fact] + public void AFullLockIsAStrengthOfOne() + { + var locker = NewShip(float3(0, 0, 0), "Launcher"); + var b = NewShip(float3(0, 0, 9)); + + Aim(locker, b); + LauncherOf(locker).Execute(5f); + Tick(0f); + + Assert.Equal(1f, Assert.Single(b.IncomingLocks).Strength, 4); + } + // The lock's progress terms: direction (lerp ^ DirectionImpact) and sensor info (info ^ SensorImpact), each // with an exponent other than 1, aimed off-axis at a partly sensed target. Lock after dt = 1: // 0.8 * (1 - 30/90)^2 * 0.8^3 = 0.18204.