Aetheria/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs
Meta Vens 60383cf00e Mining target queries tests: brute-force order, farthest, smallest angle, cost, reach, enemies
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:38:21 +02:00

269 lines
13 KiB
C#

/* 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<TargetRef> 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<TargetRef>();
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<ChunkId>();
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);
}
}