diff --git a/Assets/Scripts/ServerShared/Behaviors/Scanner.cs b/Assets/Scripts/ServerShared/Behaviors/Scanner.cs index 5d229a70f..15644c560 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Scanner.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Scanner.cs @@ -81,7 +81,8 @@ public class Scanner : Behavior, IAlwaysUpdatedBehavior, IProgressBehavior return true; } - // Every tick, after Execute: a scan not held this tick (out of range, untracked, or the item inactive so Execute did + // 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) { diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index d3e0b1b48..d792a995a 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -1955,17 +1955,19 @@ 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 0a0981d9e..f97cd49fc 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -103,6 +103,11 @@ public sealed partial class ContactRevealTests : IDisposable cache.Upsert(Tool("Scanner", ScannerBehavior(range: 200f, scanTime: 2f))); cache.Upsert(Tool("QuietScanner", ScannerBehavior(range: 200f, scanTime: 2f, interceptFactor: 4f))); cache.Upsert(new ConsumableItemData + { + Name = "ScannerCharge", Duration = 30f, Shape = new Shape(), + Behaviors = { ScannerBehavior(range: 200f, scanTime: 2f) } + }); + cache.Upsert(new ConsumableItemData { Name = "Pinger", Duration = 30f, Shape = new Shape(), Behaviors = { SensorBehavior(sensitivity: 2f) } @@ -250,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) + private Ship Add(float3 position, string[] mounts = null, string[] tools = null, bool thruster = false, bool agent = 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()) @@ -264,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: false); + _zone.Admit(ship, piloted: agent); // piloted: true gives the ship an AI agent 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 b308c01b5..118083cc6 100644 --- a/tests/Aetheria.Shared.Tests/ScannerTests.cs +++ b/tests/Aetheria.Shared.Tests/ScannerTests.cs @@ -2,6 +2,8 @@ using System; using System.Collections.Generic; using System.Linq; using CultMath; +using GameCult.Caching.MessagePack; +using MessagePack; using Xunit; using static CultMath.math; using float2 = CultMath.float2; @@ -279,4 +281,121 @@ public sealed partial class ContactRevealTests Assert.Equal(SignatureChannel.LaunchFlash, after.Channel); Assert.Contains(s.RevealsOf(target), r => r.Item != gun && r.Channel == null); // fixture: the scan finished } + + // ---- AScanReadsNoHostility ---- + + // Ruling ew-hostility-from-belief-ruled: a scan is an act of perception and judges nothing, so it proceeds and records the + // claim whatever the stance between the two ships, hostile or friendly, honest or lying. The stance is set both ways + // through SetIff, which decides IsHostileTo, EntityHostility and VisibleEnemies alike. + [Theory] + [InlineData(true, false)] + [InlineData(true, true)] + [InlineData(false, false)] + [InlineData(false, true)] + public void AScanReadsNoHostility(bool hostile, bool lies) + { + var (s, target) = ScanOn(); + var reactor = target.Equipment.Single(i => i.Behaviors.Any(b => b is Reactor)); + var radiator = DataOf("Radiator"); + if (lies) target.Claim(reactor, radiator); + s.SetIff(target, hostile); + target.SetIff(s, hostile); + Tick(ScanStep); + Assert.Equal(hostile, s.IsHostileTo(target)); // fixture + Assert.Equal(hostile, s.VisibleEnemies.Contains(target)); // fixture: the stance reached the readers a gate would use + + Hold(s, 16, target); + + Assert.Equal(3, s.RevealsOf(target).Count()); + var reveal = Assert.Single(s.RevealsOf(target), r => r.Item == reactor); + Assert.Same(lies ? radiator : reactor.Data, reveal.Claimed); + Assert.NotEmpty(target.IncomingPaints.Where(p => p.Mode == PaintMode.Scan)); + } + + // ---- AScanTakesTheSamePathForAnyPilot ---- + + // Ruling same-ship-same-inputs: a ship with an AI agent and one with none (the player's) scan the same target through + // the one Scanner and enter the same reveals in their books, claimed alike. Every reveal in a book comes from + // RecordReveal, whose callers are Sense, PublishInbox and RecordInspection: a second scan path for either pilot would + // leave the two books different (or one empty). + [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); + Assert.Single(_zone.Agents); // fixture: only the second ship has an agent + foreach (var scanner in new[] { player, ai }) + { + scanner.EntityInfoGathered[target] = .5f; + scanner.SetTarget(target); + } + + for (var i = 0; i < 16; i++) Hold(player, 1, target); + for (var i = 0; i < 16; i++) Hold(ai, 1, target); + + Assert.Equal(3, player.RevealsOf(target).Count()); + Func book = o => o.RevealsOf(target) + .Select(r => r.Item.Data.Name + "|" + r.Claimed.Name + "|" + (r.Channel?.ToString() ?? "scan")).OrderBy(x => x).ToArray(); + Assert.Equal(book(player), book(ai)); + } + + // ---- 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. + [Fact] + public void AConsumableScannerDecaysOnTheSameTick() + { + var data = _openCache.GetByName("ScannerCharge"); + var target = Add(float3(0, 0, 100), tools: new[] { "Reactor" }); + var s = Add(float3(0, 0, 0)); + s.ActivateConsumable(Assert.IsType(_items.CreateInstance(_items.CreateLot(data, _maker, .5f)))); + var scanner = s.FindActiveConsumable(data).Behaviors.OfType().Single(); + s.EntityInfoGathered[target] = .5f; + s.SetTarget(target); + Hold(s, 8, target); + Assert.Equal(.5f, scanner.Progress, 4); // fixture: the consumable scans like gear + + target.Position = float3(0, 0, 400); + Hold(s, 4, target); + + Assert.Equal(.25f, scanner.Progress, 4); + Hold(s, 4, target); + Assert.Equal(0f, scanner.Progress, 4); + } + + // ---- ScannerDataRoundTripsAsUnionKey51 ---- + + [Fact] + public void ScannerDataRoundTripsAsUnionKey51() + { + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(ZonePack).Assembly); + var data = new ScannerData { Range = Const(200f), ScanTime = Const(2f), InterceptFactor = Const(4f) }; + + var bytes = MessagePackSerializer.Serialize(data, options); + var reader = new MessagePackReader(bytes); + Assert.Equal(2, reader.ReadArrayHeader()); + Assert.Equal(51, reader.ReadInt32()); // sensor-catalog holds 50; a key reused across branches would collide on the wire + Assert.Equal(4, reader.ReadArrayHeader()); // Group is key 0, then the three stats + reader.Skip(); + Assert.Equal(200f, MessagePackSerializer.Deserialize(ref reader, options).Min); // key 1 Range + Assert.Equal(2f, MessagePackSerializer.Deserialize(ref reader, options).Min); // key 2 ScanTime + Assert.Equal(4f, MessagePackSerializer.Deserialize(ref reader, options).Min); // key 3 InterceptFactor + + var back = Assert.IsType(MessagePackSerializer.Deserialize(bytes, options)); + Assert.Equal(200f, back.Range.Min); + Assert.Equal(2f, back.ScanTime.Min); + Assert.Equal(4f, back.InterceptFactor.Min); + var absent = Assert.IsType(MessagePackSerializer.Deserialize( + MessagePackSerializer.Serialize(new ScannerData { Range = Const(1f), ScanTime = Const(1f) }, options), options)); + Assert.Null(absent.InterceptFactor); + + var unions = typeof(BehaviorData).GetCustomAttributes(typeof(UnionAttribute), false).Cast().ToArray(); + Assert.Single(unions, u => u.Key == 51); + Assert.Equal(unions.Length, unions.Select(u => u.Key).Distinct().Count()); + } }