diff --git a/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs b/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs new file mode 100644 index 000000000..c03c6db63 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs @@ -0,0 +1,269 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.Linq; +using CultMath; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; + +// Mining target-queries cut (docs/aetheria-release-map.md; operator 2026-10-03: "Fix the belt freeze", "Fix the +// off-by-one"; reticle-exact: "Keep reticles exact"): the reticle, next, previous and nearest presses are Entity's +// best-first questions to the zone's targeting index. Every answer is compared with a test-local sort or argmin of +// every visible candidate. +public sealed partial class MiningCut3Tests +{ + // One key press at 30k or 300k rocks of one density examines under this many rocks (measured: see the test). + private const long KeyPressExaminedCeiling = 3000; + + // A belt, a dense knot of rocks around `at` for the observer to see, and the observer: reach 120, sensor on. + private (Scene scene, Ship observer) TargetScene(int seed, float2 at, float reach = 120f, float sensitivity = 3f, int rocks = 3000) + { + var kind = Kind("Asteroid", 4f); + var rng = new System.Random(seed); + var s = BuildSceneAt(float2.zero, 1500f, 0, null, new[] { kind }, Belt(kind, Annulus(rng, rocks, 300f, 700f))); + return (s, Observer(s, at, reach, sensitivity, Falloff())); + } + + // Visible ships at every range, in the observer's VisibleEntities. + private static Ship Sight(Scene s, Ship observer, float2 at, bool enemy = false) + { + var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings()); + s.Zone.Entities.Add(ship); + Hold(ship, at); + observer.VisibleEntities.Add(ship); + if (enemy) observer.VisibleEnemies.Add(ship); + return ship; + } + + // Every target a press may pick, as a plain scan: other visible ships, and visible chunks within reach, by + // planar distance then entity before chunk, entities by zone order, chunks by field key then index. + private static List ByDistance(Scene s, Ship observer, float reach) + { + var at = observer.Position.xz; + var all = new List<(TargetRef target, float distance)>(); + foreach (var e in observer.VisibleEntities) + if (e != observer) all.Add((e, length(e.Position.xz - at))); + foreach (var c in AllChunks(s)) + if (s.Zone.ChunkExists(c) && length(At(s, c) - at) <= reach && observer.ChunkVisible(c)) + all.Add((c, length(At(s, c) - at))); + return all.OrderBy(x => x.distance) + .ThenBy(x => x.target.Chunk.HasValue ? 1 : 0) + .ThenBy(x => x.target.Chunk.HasValue ? 0 : s.Zone.Entities.IndexOf(x.target.Entity)) + .ThenBy(x => x.target.Chunk.HasValue ? x.target.Chunk.Value.Field.Value : "", StringComparer.Ordinal) + .ThenBy(x => x.target.Chunk.HasValue ? x.target.Chunk.Value.Index : 0) + .Select(x => x.target).ToList(); + } + + private static void SightShips(Scene s, Ship observer, float2 at) + { + // Three ships at one distance (a tie), one past mining reach, one far beyond it. + Sight(s, observer, at + float2(40, 0)); + Sight(s, observer, at + float2(0, 40)); + Sight(s, observer, at + float2(-40, 0)); + Sight(s, observer, at + float2(250, 0)); + Sight(s, observer, at + float2(0, -900)); + } + + // Repeated Next from nothing visits every target in the scan's order and wraps; Previous is the reverse. Ties + // between ships at one distance, ships past reach, and rocks both near and at reach are all in the order. + [Fact] + public void NextAndPreviousWalkTheBruteForceOrder() + { + var walked = 0; + foreach (var (seed, at) in new[] { (7, float2(500, 0)), (8, float2(-350, 400)), (9, float2(0, -650)) }) + { + var (s, observer) = TargetScene(seed, at); + SightShips(s, observer, at); + var order = ByDistance(s, observer, 120f); + Assert.True(order.Count(t => t.Chunk.HasValue) >= 8 && order.Count(t => t.Entity != null) == 5, $"degenerate: {order.Count} candidates"); + + observer.SetTarget(TargetRef.None); + for (var i = 0; i <= order.Count; i++) + { + Assert.True(observer.TargetNext()); + Assert.Equal(order[i % order.Count], observer.Target.Value); + walked++; + } + observer.SetTarget(TargetRef.None); + for (var i = 0; i <= order.Count; i++) + { + Assert.True(observer.TargetPrevious()); + Assert.Equal(order[(order.Count - 1 - i % order.Count)], observer.Target.Value); + walked++; + } + // From every target, Next is its successor and Previous its predecessor. + for (var i = 0; i < order.Count; i++) + { + observer.SetTarget(order[i]); + observer.TargetNext(); + Assert.Equal(order[(i + 1) % order.Count], observer.Target.Value); + observer.SetTarget(order[i]); + observer.TargetPrevious(); + Assert.Equal(order[(i + order.Count - 1) % order.Count], observer.Target.Value); + } + } + Assert.True(walked > 60, $"degenerate: {walked} steps"); + } + + // Operator, "Fix the off-by-one": with nothing targeted, Previous takes the farthest candidate, not the one + // before it. + [Fact] + public void PreviousWithNoTargetPicksTheFarthest() + { + var at = float2(500, 0); + var (s, observer) = TargetScene(7, at); + Sight(s, observer, at + float2(60, 0)); + var order = ByDistance(s, observer, 120f); + Assert.True(order.Count > 3); + Assert.True(order[^1].Chunk.HasValue, "the farthest should be a rock here"); + Assert.True(observer.TargetPrevious()); + Assert.Equal(order[^1], observer.Target.Value); + Assert.NotEqual(order[^2], observer.Target.Value); + + // With a far ship as well, the farthest is the ship. + var far = Sight(s, observer, at + float2(0, -900)); + observer.SetTarget(TargetRef.None); + Assert.True(observer.TargetPrevious()); + Assert.Equal(new TargetRef(far), observer.Target.Value); + } + + // The reticle's pick is the scan's least planar angle, over ships and rocks alike, in many look directions; the + // same press again clears the target. + [Fact] + public void TheReticlePicksTheSmallestAngle() + { + var at = float2(-350, 400); + var (s, observer) = TargetScene(8, at); + SightShips(s, observer, at); + var order = ByDistance(s, observer, 120f); + Assert.True(order.Count > 15); + float2 Where(TargetRef t) => t.Chunk is ChunkId c ? At(s, c) : t.Entity.Position.xz; + var rng = new System.Random(3); + var picked = new HashSet(); + for (var i = 0; i < 60; i++) + { + var heading = rng.NextDouble() * 2 * Math.PI; + observer.LookDirection = float3((float) Math.Cos(heading), 0, (float) Math.Sin(heading)); + double Angle(TargetRef t) + { + var to = Where(t) - at; + return Math.Abs(Math.Atan2((double) Math.Cos(heading) * to.y - Math.Sin(heading) * to.x, + Math.Cos(heading) * to.x + Math.Sin(heading) * to.y)); + } + var expected = order.OrderBy(Angle).First(); + observer.SetTarget(TargetRef.None); + Assert.True(observer.TargetUnderReticle()); + Assert.Equal(expected, observer.Target.Value); + picked.Add(expected); + Assert.True(observer.TargetUnderReticle()); + Assert.True(observer.Target.Value.IsNone, "the second press on the same target clears it"); + } + Assert.True(picked.Count > 10, $"degenerate: {picked.Count} distinct picks"); + } + + // The operator's rule for F1: a key press does not grow with the belt. TargetNext, from nothing and from a held + // rock, at 30k and 300k rocks of one density, examines about as many in both, under a fixed ceiling. + // Measured at this commit: see ExaminedAtPress. + [Fact] + public void AKeyPressDoesNotGrowWithTheBelt() + { + var kind = Kind("Asteroid", 4f); + long Press(int count, bool held, bool reticle) + { + var outer = sqrt(2000f * 2000f + count * 100f / PI); + var s = BuildSceneAt(float2.zero, 20000f, 0, null, new[] { kind }, Belt(kind, Annulus(new System.Random(21), count, 2000f, outer))); + var at = 2100f * float2(cos(1f), sin(1f)); + var observer = Observer(s, at, 150f, 3f, Falloff()); + observer.LookDirection = float3(1, 0, 0); + var order = ByDistance(s, observer, 150f); + Assert.True(order.Count > 5); + if (held) observer.SetTarget(order[order.Count / 2]); + var before = s.Zone.Targets.Examined; + Assert.True(reticle ? observer.TargetUnderReticle() : observer.TargetNext()); + var cost = s.Zone.Targets.Examined - before; + if (!reticle) Assert.Equal(order[held ? order.Count / 2 + 1 : 0], observer.Target.Value); + return cost; + } + foreach (var (held, reticle) in new[] { (false, false), (true, false), (false, true) }) + { + var small = Press(30_000, held, reticle); + var large = Press(300_000, held, reticle); + Assert.True(large < 2 * small && small < 2 * large, $"held {held} reticle {reticle}: examined {small} at 30k, {large} at 300k"); + Assert.True(large < KeyPressExaminedCeiling, $"held {held} reticle {reticle}: examined {large}, ceiling {KeyPressExaminedCeiling}"); + } + } + + // No press ever picks a rock this ship cannot see, or one beyond mining reach; launchers add no reach (Q12 A). + [Fact] + public void DarkAndOutOfReachRocksAreNeverPicked() + { + var at = float2(500, 0); + // A dim sensor: only some rocks in reach are visible. + var (s, observer) = TargetScene(7, at, reach: 150f, sensitivity: .35f); + var inReach = AllChunks(s).Where(c => length(At(s, c) - at) <= 150f).ToList(); + var visible = inReach.Where(observer.ChunkVisible).ToList(); + Assert.True(visible.Count > 3 && visible.Count < inReach.Count, $"degenerate: {visible.Count} of {inReach.Count} visible"); + var picks = new HashSet(); + var rng = new System.Random(5); + for (var i = 0; i < 200; i++) + { + var heading = rng.NextDouble() * 2 * Math.PI; + observer.LookDirection = float3((float) Math.Cos(heading), 0, (float) Math.Sin(heading)); + observer.SetTarget(TargetRef.None); + switch (i % 3) + { + case 0: observer.TargetUnderReticle(); break; + case 1: observer.TargetNext(); observer.TargetNext(); break; + default: observer.TargetPrevious(); observer.TargetPrevious(); break; + } + if (observer.Target.Value.Chunk is ChunkId chunk) + { + Assert.Contains(chunk, visible); + picks.Add(chunk); + } + } + Assert.True(picks.Count > 3, $"degenerate: {picks.Count} distinct rocks picked"); + + // A launcher's longer range is no reach: a ship fitted with one alone picks no rock. + var launcher = Gear("Launcher", new LockWeaponData { Range = Constant(400f), LockSpeed = Constant(.5f), LockAngle = Constant(30f), DirectionImpact = Constant(1f) }); + s.Cache.Upsert(launcher); + var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings()); + Assert.True(ship.TryEquip(Mint(s, s.Eye))); + Assert.True(ship.TryEquip(Mint(s, launcher))); + s.Zone.Entities.Add(ship); + Hold(ship, at); + ship.LookDirection = float3(0, 0, 1); + ship.Activate(); + Tick((ship, at)); + Assert.Contains(ship.Weapons, w => w.Range == 400f); + Assert.True(AllChunks(s).Any(c => length(At(s, c) - at) <= 400f && ship.ChunkVisible(c)), "there are rocks within the launcher's range"); + Assert.False(ship.TargetUnderReticle()); + Assert.False(ship.TargetNext()); + Assert.False(ship.TargetPrevious()); + Assert.True(ship.Target.Value.IsNone); + } + + // Nearest stays enemies only: a nearer rock and a nearer friendly ship are ignored. + [Fact] + public void NearestStaysEnemiesOnly() + { + var at = float2(500, 0); + var (s, observer) = TargetScene(7, at); + var friend = Sight(s, observer, at + float2(10, 0)); + var farEnemy = Sight(s, observer, at + float2(0, 90), enemy: true); + var nearEnemy = Sight(s, observer, at + float2(60, 0), enemy: true); + Assert.True(observer.TargetNearestEnemy()); + Assert.Equal(new TargetRef(nearEnemy), observer.Target.Value); + + observer.VisibleEnemies.Clear(); + observer.SetTarget(friend); + Assert.False(observer.TargetNearestEnemy()); + Assert.Equal(new TargetRef(friend), observer.Target.Value); + Assert.NotEqual(new TargetRef(farEnemy), observer.Target.Value); + } +}