Scanner fix batch: consumable hosts decay on the same tick; hostility, pilot-path, consumable and wire-key-51 tests

ConsumableItemEffect.Update now runs Execute before the always-updated pass, as EquippedItem.Update does, so a consumable-hosted scanner decays on the tick the scan breaks. Tests: AScanReadsNoHostility (hostile/friendly, honest/lying), AScanTakesTheSamePathForAnyPilot (AI-agent ship and agentless ship), AConsumableScannerDecaysOnTheSameTick, ScannerDataRoundTripsAsUnionKey51. Not yet verified at this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-14 04:03:20 +02:00
commit 26d0e66ff5
4 changed files with 133 additions and 6 deletions

View file

@ -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)
{

View file

@ -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.

View file

@ -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<ItemData>(_items.ItemData.GetByName<HullData>("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

View file

@ -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<Entity, string[]> 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<ConsumableItemData>("ScannerCharge");
var target = Add(float3(0, 0, 100), tools: new[] { "Reactor" });
var s = Add(float3(0, 0, 0));
s.ActivateConsumable(Assert.IsType<ConsumableItem>(_items.CreateInstance(_items.CreateLot(data, _maker, .5f))));
var scanner = s.FindActiveConsumable(data).Behaviors.OfType<Scanner>().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<BehaviorData>(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<PerformanceStat>(ref reader, options).Min); // key 1 Range
Assert.Equal(2f, MessagePackSerializer.Deserialize<PerformanceStat>(ref reader, options).Min); // key 2 ScanTime
Assert.Equal(4f, MessagePackSerializer.Deserialize<PerformanceStat>(ref reader, options).Min); // key 3 InterceptFactor
var back = Assert.IsType<ScannerData>(MessagePackSerializer.Deserialize<BehaviorData>(bytes, options));
Assert.Equal(200f, back.Range.Min);
Assert.Equal(2f, back.ScanTime.Min);
Assert.Equal(4f, back.InterceptFactor.Min);
var absent = Assert.IsType<ScannerData>(MessagePackSerializer.Deserialize<BehaviorData>(
MessagePackSerializer.Serialize<BehaviorData>(new ScannerData { Range = Const(1f), ScanTime = Const(1f) }, options), options));
Assert.Null(absent.InterceptFactor);
var unions = typeof(BehaviorData).GetCustomAttributes(typeof(UnionAttribute), false).Cast<UnionAttribute>().ToArray();
Assert.Single(unions, u => u.Key == 51);
Assert.Equal(unions.Length, unions.Select(u => u.Key).Distinct().Count());
}
}