From b26fbbaa954de162ffb33197b0da2e6b27bad2c8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 04:23:13 +0200 Subject: [PATCH] Scanner decays from the zone clock, not tick order; revert the consumable reorder bdc8f1d0 ran Execute before the always-updated pass in ConsumableItemEffect.Update; that moved StatModifier timing for every consumable (TheOverdriveLocksAndBoosts failed). The scan's decay is now derived from the zone clock since the last held tick (Scanner.Progress, one site), so it is the same on the tick it breaks for an equipment host and a consumable host, and Scanner is no longer an IAlwaysUpdatedBehavior. The pilot-path test attaches a task-less Agent: a patrolling Minion needs zone orbits. bdc8f1d0 does not pass its own suite; this commit is the intended state. Co-Authored-By: Claude Opus 5.5 --- .../Scripts/ServerShared/Behaviors/Scanner.cs | 42 +++++++++++-------- Assets/Scripts/ServerShared/Entity.cs | 8 ++-- .../ContactRevealTests.cs | 4 +- tests/Aetheria.Shared.Tests/ScannerTests.cs | 9 ++-- 4 files changed, 34 insertions(+), 29 deletions(-) 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() {