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:
parent
b026ea6eb6
commit
26d0e66ff5
4 changed files with 133 additions and 6 deletions
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue