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; using float3 = CultMath.float3; // Inspection (rulings quiet-gear-identifiable-hull-layout-known, scan-held-beam, scan-is-an-act, reveal-ends-on-perception, // sensor-stat-set): a scanner held on its ship's tracked target within Range for ScanTime reveals everything fitted to it, // as claimed, and is itself an emission the target is warned of and a third party can hear. The fixtures are the // catalog's: "Scanner" has Range 200 and ScanTime 2, "QuietScanner" the same with intercept factor 4 (heard at 50). A tick // is 1/8 s, so a scan builds 1/16 a tick and finishes on the sixteenth with no float drift. The scanner's ship keeps // its track on the target at .5 each tick (above the detection gate, below the one at which gear is resolved, so // Sense neither loses the track nor perceives a destruction). Every fixture drives the production path, ticking inside // Zone.Update. public sealed partial class ContactRevealTests { private const float ScanStep = .125f; private static ScannerData ScannerBehavior(float range, float scanTime, float? interceptFactor = null) => new ScannerData { Range = Const(range), ScanTime = Const(scanTime), InterceptFactor = interceptFactor.HasValue ? Const(interceptFactor.Value) : null }; private static Scanner ScannerOf(Entity e) => e.Equipment.SelectMany(i => i.Behaviors).OfType().Single(); private static EquippedItem ScannerItemOf(Entity e) => e.Equipment.Single(i => i.Behaviors.Any(b => b is Scanner)); // A scanner ship at the origin, with the named scanner and no sensor (an AI ship with a sensor pings once it loses its // track, which would paint the target beside the scan), tracking and targeting a ship `distance` // ahead that carries a reactor, a radiator and a suppressor, none of which ever acts. private (Ship scanner, Ship target) ScanOn(string scanner = "Scanner", float distance = 100f, float info = .5f, string[] targetMounts = null) { var target = Add(float3(0, 0, distance), targetMounts, tools: new[] { "Reactor", "Radiator", "SuppressorCold" }); var s = Add(float3(0, 0, 0), tools: new[] { scanner }); s.EntityInfoGathered[target] = info; s.SetTarget(target); Assert.True(ReferenceEquals(target, s.Target.Value.Entity), "fixture: the scanner's ship targets the ship it scans"); // not Assert.Same: a failing Entity argument is formatted as a graph and overflows the test host return (s, target); } // Ticks `ticks` steps, holding the observer's track on each of `tracked` at `info` before each. private void Hold(Entity observer, int ticks, float info, params Entity[] tracked) { for (var i = 0; i < ticks; i++) { foreach (var t in tracked) observer.EntityInfoGathered[t] = info; Tick(ScanStep); } } private void Hold(Entity observer, int ticks, params Entity[] tracked) => Hold(observer, ticks, .5f, tracked); private static EquippedItem[] FittedBesidesTheHull(Entity e) => e.Equipment.Where(i => i != e.EquippedHull).ToArray(); // ---- AScanRevealsQuietGear ---- [Fact] public void AScanRevealsQuietGear() { var (s, target) = ScanOn(); var quiet = FittedBesidesTheHull(target); Assert.Equal(3, quiet.Length); Assert.Empty(s.RevealsOf(target)); // fixture: nothing acts Hold(s, 15, target); Assert.Empty(s.RevealsOf(target)); Assert.Equal(15f / 16f, ScannerOf(s).Progress, 4); Hold(s, 1, target); foreach (var item in quiet) { var reveal = Assert.Single(s.RevealsOf(target), r => r.Item == item); Assert.Null(reveal.Channel); AssertBearing(0f, 1f, reveal.Bearing); // normalize((target - scanner).xz) Assert.Equal(_zone.Time, reveal.Time); } Assert.Equal(0f, ScannerOf(s).Progress); // a finished scan starts over } // ---- AScanSkipsTheHull ---- [Fact] public void AScanSkipsTheHull() { var (s, target) = ScanOn(); Assert.Contains(target.EquippedHull, target.Equipment); // fixture: the hull is an item of Equipment Hold(s, 16, target); Assert.Equal(3, s.RevealsOf(target).Count()); // fixture: the scan finished Assert.DoesNotContain(s.RevealsOf(target), r => r.Item == target.EquippedHull); } // ---- AScanSkipsDestroyedGear ---- [Fact] public void AScanSkipsDestroyedGear() { var (s, target) = ScanOn(); var items = FittedBesidesTheHull(target); items[0].EquippableItem.Durability = 0f; Assert.True(items[0].EquippableItem.IsDestroyed); Hold(s, 16, target); Assert.DoesNotContain(s.RevealsOf(target), r => r.Item == items[0]); Assert.Equal(2, s.RevealsOf(target).Count()); } // ---- AScanRecordsTheClaim ---- [Fact] public void AScanRecordsTheClaim() { var (s, target) = ScanOn(); var reactor = target.Equipment.Single(i => i.Behaviors.Any(b => b is Reactor)); var radiator = DataOf("Radiator"); target.Claim(reactor, radiator); Hold(s, 16, target); var reveal = Assert.Single(s.RevealsOf(target), r => r.Item == reactor); Assert.Same(radiator, reveal.Claimed); Assert.NotSame(reactor.Data, reveal.Claimed); } // ---- AScanNeedsATrackAndRange ---- [Theory] [InlineData(199f, true, true)] [InlineData(201f, true, false)] [InlineData(100f, false, false)] public void AScanNeedsATrackAndRange(float distance, bool tracked, bool builds) { var third = Add(float3(40, 0, 0), new[] { "Eye" }); // within 200 of the scanner: it hears an emission var info = tracked ? .5f : 0f; var (s, target) = ScanOn(distance: distance, info: info); Assert.Equal(tracked, s.VisibleEntities.Contains(target)); // fixture Hold(s, 4, info, target); Assert.Equal(builds, ScannerOf(s).Progress > 0f); Assert.Equal(builds, target.IncomingPaints.Count > 0); Assert.Equal(builds, target.RevealsOf(s).Any()); Assert.Equal(builds, third.RevealsOf(s).Any()); } // ---- ABrokenScanDecays ---- // A scan at .5 broken for half of ScanTime (one second) falls to 0, a quarter in half of that: by range (Execute // runs and does not hold) and by the item switched off (Execute does not run at all). [Theory] [InlineData(true)] [InlineData(false)] public void ABrokenScanDecays(bool byRange) { var (s, target) = ScanOn(); Hold(s, 8, target); Assert.Equal(.5f, ScannerOf(s).Progress, 4); if (byRange) target.Position = float3(0, 0, 400); else ScannerItemOf(s).Enabled.Value = false; Hold(s, 4, target); Assert.Equal(.25f, ScannerOf(s).Progress, 4); Hold(s, 4, target); Assert.Equal(0f, ScannerOf(s).Progress, 4); Hold(s, 2, target); Assert.Equal(0f, ScannerOf(s).Progress, 4); // never below 0 } // ---- ABrokenScanResumesFromWhatDecayed ---- // A scan at .5 loses a sixteenth for each tick it is broken, and a held tick after the gap builds on what is left (the // held tick itself is not a decay tick): .5, two broken ticks .375, two held ticks .5. [Fact] public void ABrokenScanResumesFromWhatDecayed() { var (s, target) = ScanOn(); Hold(s, 8, target); Assert.Equal(.5f, ScannerOf(s).Progress, 4); target.Position = float3(0, 0, 400); Hold(s, 2, target); Assert.Equal(.375f, ScannerOf(s).Progress, 4); target.Position = float3(0, 0, 100); Hold(s, 2, target); Assert.Equal(.5f, ScannerOf(s).Progress, 4); } // ---- ATargetChangeResetsTheScan ---- [Fact] public void ATargetChangeResetsTheScan() { var (s, first) = ScanOn(); var second = Add(float3(100, 0, 0)); Hold(s, 11, first, second); Assert.Equal(11f / 16f, ScannerOf(s).Progress, 4); s.SetTarget(second); Hold(s, 1, first, second); Assert.Equal(1f / 16f, ScannerOf(s).Progress, 4); // reset, then one tick on the new target Assert.Empty(s.RevealsOf(first)); } // ---- AScanReadsNothingOnAnotherTarget ---- // A scan built on one ship is that ship's: with the scanner switched off at 11/16 the player retargets, and the progress // against the new target is 0 at once, not the old scan decaying (the HUD's outgoing indicator reads it). [Fact] public void AScanReadsNothingOnAnotherTarget() { var (s, first) = ScanOn(); var second = Add(float3(100, 0, 0)); Hold(s, 11, first, second); Assert.Equal(11f / 16f, ScannerOf(s).Progress, 4); ScannerItemOf(s).Enabled.Value = false; s.SetTarget(second); Hold(s, 1, first, second); Assert.Equal(0f, ScannerOf(s).Progress, 4); } // ---- TheScannedShipIsWarned ---- // A paint made in a tick is published the next one, so after four ticks the target holds the third tick's paint. [Fact] public void TheScannedShipIsWarned() { var (s, target) = ScanOn(); Hold(s, 4, target); var paint = Assert.Single(target.IncomingPaints); Assert.Equal(PaintMode.Scan, paint.Mode); Assert.Equal(3f / 16f, paint.Strength, 4); Assert.Same(DataOf("Scanner"), paint.EmitterClass); AssertBearing(0f, -1f, paint.Bearing); // normalize((scanner - target).xz) var warned = Assert.Single(target.RevealsOf(s)); Assert.Same(ScannerItemOf(s), warned.Item); Assert.Equal(SignatureChannel.Emission, warned.Channel); } // ---- AThirdPartyHearsTheScanner ---- [Theory] [InlineData("QuietScanner", 49f, true)] [InlineData("QuietScanner", 51f, false)] [InlineData("Scanner", 199f, true)] [InlineData("Scanner", 201f, false)] public void AThirdPartyHearsTheScanner(string scanner, float distance, bool heard) { var (s, target) = ScanOn(scanner); var third = Add(float3(-distance, 0, 0), new[] { "Eye" }); Hold(s, 3, target); Assert.Empty(third.IncomingPaints); // fixture: only the target is painted if (!heard) { Assert.Empty(third.RevealsOf(s)); return; } var reveal = Assert.Single(third.RevealsOf(s)); Assert.Same(ScannerItemOf(s), reveal.Item); Assert.Equal(SignatureChannel.Emission, reveal.Channel); AssertBearing(1f, 0f, reveal.Bearing); // normalize((scanner - third).xz) } // ---- AFinishedScanMakesTheReactorSelectable ---- [Fact] public void AFinishedScanMakesTheReactorSelectable() { var (s, target) = ScanOn(); var reactor = target.Equipment.Single(i => i.Behaviors.Any(b => b is Reactor)); Hold(s, 15, target); Assert.False(FireControl.IsRevealed(s, reactor)); Assert.False(s.TrySelectTargetItem(reactor)); Hold(s, 1, target); Assert.True(FireControl.IsRevealed(s, reactor)); Assert.True(s.TrySelectTargetItem(reactor)); } // ---- AScanEndsNothing ---- // A gun revealed by firing and then destroyed, while the observer's track stays between the detection and gear // thresholds (so Sense perceives no destruction): a scan neither ends its reveal nor refreshes it. [Fact] public void AScanEndsNothing() { var target = Add(float3(0, 0, 100), new[] { "Gun" }); var s = Add(float3(0, 0, 0), new[] { "Eye" }, new[] { "Scanner" }); var gun = Guns(target)[0]; var firedAt = Fire(target, gun); var before = Assert.Single(s.RevealsOf(target), r => r.Item == gun); Assert.Equal(firedAt, before.Time); // fixture: the gun is in the book gun.EquippableItem.Durability = 0f; s.EntityInfoGathered[target] = .5f; s.SetTarget(target); Hold(s, 16, target); var after = Assert.Single(s.RevealsOf(target), r => r.Item == gun); Assert.Equal(firedAt, after.Time); 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. The target carries a sensor that // tracks the scanner, so a gate on the target's belief about its scanner would stop the scan; the scan builds at the pace // of one tick's step, shows nothing a tick before it is done, and a third party hears it, in every stance. [Theory] [InlineData(true, false)] [InlineData(true, true)] [InlineData(false, false)] [InlineData(false, true)] public void AScanReadsNoHostility(bool hostile, bool lies) { var (s, target) = ScanOn(targetMounts: new[] { "Eye" }); var third = Add(float3(-40, 0, 0), new[] { "Eye" }); // inside Range 200 of the scanner: it hears the emission 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); target.EntityInfoGathered[s] = .5f; 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 Assert.Equal(hostile, target.IsHostileTo(s)); // fixture: the target's sensor tracks the scanner as an enemy or a friend var built = (int) MathF.Round(ScannerOf(s).Progress * 16f); for (var i = 0; i < 15 - built; i++) { target.EntityInfoGathered[s] = .5f; Hold(s, 1, target); } Assert.Equal(15f / 16f, ScannerOf(s).Progress, 4); // the pace, a tick short of ScanTime Assert.Empty(s.RevealsOf(target)); Hold(s, 1, target); Assert.Equal(4, s.RevealsOf(target).Count()); // the reactor, radiator, suppressor and the target's own sensor Assert.Equal(SignatureChannel.Emission, Assert.Single(third.RevealsOf(s)).Channel); 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() { 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" }); var agent = new Minion(ai, FactionDoctrine.Default(ai.ItemManager.GameplaySettings).Combatant); // the AI pilot, ticked in Zone.Update beside the scan: it takes the combat state once it has a target _zone.Agents.Add(agent); 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); } void Step(int ticks) { for (var i = 0; i < ticks; i++) { player.EntityInfoGathered[target] = .5f; ai.EntityInfoGathered[target] = .5f; Tick(ScanStep); } } Step(15); Assert.IsType(typeof(Agent).GetField("_currentState", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance).GetValue(agent)); // fixture: the AI is fighting while it scans Assert.Empty(player.RevealsOf(target)); // neither pilot has a scan before ScanTime Assert.Empty(ai.RevealsOf(target)); Step(1); 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)); } // ---- AFinishedScanListsTheGearOnTheSchematic ---- // Ruling visibility-per-capability-reveal: what a scan reveals shows on the enemy schematic, as claimed. The target carries // a gun, two reactors (the gun's own and a fitted one), a radiator, a suppressor and a targeting system; the fitted reactor // claims to be a radiator, and the hull's own flash is in the book too. A row is made for each scanned item and none for // the hull: weapons first with their rated range, then the gear in fitted order, each as claimed and with no range. [Fact] public void AFinishedScanListsTheGearOnTheSchematic() { var target = Add(float3(30, 0, 40), new[] { "Gun" }, new[] { "Reactor", "Radiator", "SuppressorCold" }); var s = Add(float3(0, 0, 0), new[] { "Eye" }, new[] { "Scanner" }); var fitted = FittedBesidesTheHull(target); var gun = Guns(target)[0]; var reactor = target.Equipment.Where(i => i.Behaviors.Any(b => b is Reactor)).Last(); target.Claim(reactor, DataOf("Radiator")); target.SetSignature(new object(), SignatureChannel.LaunchFlash, 1000000f, target.EquippedHull); Tick(.1f); Assert.Equal(target.EquippedHull, Assert.Single(s.RevealsOf(target)).Item); // fixture: the hull is in the book, as a hull class Assert.IsAssignableFrom(s.RevealsOf(target).Single().Claimed); s.EntityInfoGathered[target] = .5f; s.SetTarget(target); Hold(s, 15, target); Assert.Empty(s.SchematicRows(target)); // fixture: no row before the scan is done Hold(s, 1, target); var rows = s.SchematicRows(target).ToArray(); Assert.Equal(fitted.Length, rows.Length); // every scanned item Assert.DoesNotContain(rows, r => r.Item == target.EquippedHull || r.Claimed is HullData); Assert.Same(gun, rows[0].Item); // weapons first Assert.Equal(((WeaponData) DataOf("Gun").Behaviors.Single(b => b is WeaponData)).RatedRange, rows[0].Range); var gear = rows.Skip(1).ToArray(); Assert.Equal(fitted.Where(i => i != gun).ToArray(), gear.Select(r => r.Item).ToArray()); // fitted order Assert.All(gear, r => Assert.Null(r.Range)); Assert.All(gear, r => Assert.Same(r.Item == reactor ? DataOf("Radiator") : r.Item.Data, r.Claimed)); } // ---- AConsumableScannerDecaysOnTheSameTick ---- // 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() { 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()); } }