diff --git a/Assets/Scripts/ServerShared/Behaviors/Scanner.cs b/Assets/Scripts/ServerShared/Behaviors/Scanner.cs index 15644c560..d94a8b978 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Scanner.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Scanner.cs @@ -36,13 +36,18 @@ public class ScannerData : BehaviorData } } -public class Scanner : Behavior, IAlwaysUpdatedBehavior, IProgressBehavior +public class Scanner : Behavior, IProgressBehavior { private readonly ScannerData _data; private Entity _target; - private bool _held; + private float _built; + private double _heldAt; - public float Progress { get; private set; } + // The scan's progress now: what Execute last built, less ScanTime-relative decay over the time since a tick last held + // it (ruling scan-held-beam: a broken scan decays over ScanTime, the item inactive or destroyed included). Derived from + // the zone clock, never from tick order, so a scanner decays on the tick it breaks whether its host runs Execute + // before the always-updated pass (EquippedItem) or after it (ConsumableItemEffect). The one decay site. + public float Progress => Remaining(_heldAt); public Scanner(ScannerData data, EquippedItem item) : base(data, item) { @@ -53,40 +58,41 @@ public class Scanner : Behavior, IAlwaysUpdatedBehavior, IProgressBehavior _data = data; } + private float Remaining(double since) + { + if (_built <= 0f) return 0f; + var now = Entity.Zone?.ExactTime ?? since; + return saturate(_built - (float) (now - since) / Evaluate(_data.ScanTime)); + } + // Runs only while the item is Active. The scan builds while the ship's own Target is a tracked ship within Range, and // is painted and emitted on every tick it builds; at 1 the target's fitted items enter the book and the scan starts over. + // A tick that does not hold the scan builds nothing, and the zone clock carries its decay (Progress). public override bool Execute(float dt) { var target = Entity.Target.Value.Entity; if (_target != target) { - Progress = 0; + _built = 0f; _target = target; } var range = Evaluate(_data.Range); if (target != null && Entity.VisibleEntities.Contains(target) && Entity.TargetRange <= range) { - Progress = saturate(Progress + dt / Evaluate(_data.ScanTime)); - target.ReceivePaint(Entity, Item, PaintMode.Scan, Progress); + // Decay over the ticks since the last held one, not counting this tick, which holds. + var progress = saturate(Remaining(_heldAt + dt) + dt / Evaluate(_data.ScanTime)); + _built = progress; + _heldAt = Entity.Zone?.ExactTime ?? _heldAt; + target.ReceivePaint(Entity, Item, PaintMode.Scan, progress); Entity.Emit(Item, range / (_data.InterceptFactor == null ? 1f : Evaluate(_data.InterceptFactor))); - _held = true; - if (Progress >= 1f) + if (progress >= 1f) { Entity.RecordInspection(target); - Progress = 0; + _built = 0f; } } return true; } - - // Every tick, after Execute on either host (EquippedItem.Update and ConsumableItemEffect.Update both run Execute first): - // a scan not held this tick (out of range, untracked, or the item inactive so Execute did - // not run) decays over ScanTime (ruling scan-held-beam). The one decay site. - public void Update(float delta) - { - if (!_held) Progress = saturate(Progress - delta / Evaluate(_data.ScanTime)); - _held = false; - } } diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index d792a995a..d3e0b1b48 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -1955,19 +1955,17 @@ public class ConsumableItemEffect : IStatContext .ToArray(); } - // Execute, then the always-updated pass, as EquippedItem.Update orders them: an always-updated behaviour that reads - // what Execute marked this tick (Scanner's held mark) sees the same tick on either host. public void Update(float delta) { + foreach (var behavior in Behaviors) + if(behavior is IAlwaysUpdatedBehavior alwaysUpdatedBehavior) alwaysUpdatedBehavior.Update(delta); + foreach (var behavior in Behaviors) { if (!behavior.Execute(delta)) break; } - foreach (var behavior in Behaviors) - if(behavior is IAlwaysUpdatedBehavior alwaysUpdatedBehavior) alwaysUpdatedBehavior.Update(delta); - RemainingDuration -= delta; // Progress moves every tick; a stat with a ConsumableProgress term must recompute against it, the same // way EquippedItem signals Heat and Durability from UpdatePerformance below. diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index f97cd49fc..f39d4e114 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -255,7 +255,7 @@ public sealed partial class ContactRevealTests : IDisposable // A ship at `position` with `mounts` on its sensor hardpoints in order, the tools on free cells and a thruster // on the centre column. A ship with a gun also gets the reactor and targeting a gun needs to fire. - private Ship Add(float3 position, string[] mounts = null, string[] tools = null, bool thruster = false, bool agent = false) + private Ship Add(float3 position, string[] mounts = null, string[] tools = null, bool thruster = false) { var hullRef = _items.ItemData.RefOf(_items.ItemData.GetByName("Rack")); var ship = new Ship(_items, _zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = _nextLot++ }, new EntitySettings()) @@ -269,7 +269,7 @@ public sealed partial class ContactRevealTests : IDisposable Assert.True(ship.TryFindSpace(item, out var at)); Assert.True(ship.TryEquip(item, at)); } - _zone.Admit(ship, piloted: agent); // piloted: true gives the ship an AI agent + _zone.Admit(ship, piloted: false); ship.Position = position; ship.Aim = float3(0, 0, 1); _zone.Update(0f); // brings the gear online diff --git a/tests/Aetheria.Shared.Tests/ScannerTests.cs b/tests/Aetheria.Shared.Tests/ScannerTests.cs index 67e728fab..20872ae7c 100644 --- a/tests/Aetheria.Shared.Tests/ScannerTests.cs +++ b/tests/Aetheria.Shared.Tests/ScannerTests.cs @@ -321,12 +321,12 @@ public sealed partial class ContactRevealTests [Fact] public void AScanTakesTheSamePathForAnyPilot() { - _zone.AddOrbit(new OrbitData { Distance = 1000f }); // the agent patrols one; an orbit-less zone gives its circuit no first stop var target = Add(float3(0, 0, 100), tools: new[] { "Reactor", "Radiator", "SuppressorCold" }); var reactor = target.Equipment.Single(i => i.Behaviors.Any(b => b is Reactor)); target.Claim(reactor, DataOf("Radiator")); var player = Add(float3(-20, 0, 0), tools: new[] { "Scanner" }); - var ai = Add(float3(20, 0, 0), tools: new[] { "Scanner" }, agent: true); + var ai = Add(float3(20, 0, 0), tools: new[] { "Scanner" }); + _zone.Agents.Add(new Agent(ai)); // the AI pilot, ticked in Zone.Update beside the scan; a task-less agent needs no orbit to patrol Assert.Single(_zone.Agents); // fixture: only the second ship has an agent foreach (var scanner in new[] { player, ai }) { @@ -356,8 +356,9 @@ public sealed partial class ContactRevealTests // ---- AConsumableScannerDecaysOnTheSameTick ---- - // Equipment and consumable hosts run Execute before the always-updated pass, so a scan broken by range loses the same - // dt/ScanTime on the tick it breaks: .5 falls to .25 in four ticks, as ABrokenScanDecays has it for gear. + // The two hosts order Execute and the always-updated pass differently (ConsumableItemEffect.Update runs the pass first), + // so the scan's decay must not depend on that order: a scan broken by range loses dt/ScanTime on the tick it breaks, and + // .5 falls to .25 in four ticks, as ABrokenScanDecays has it for gear. [Fact] public void AConsumableScannerDecaysOnTheSameTick() {