Aetheria/Assets/Scripts/ServerShared/Targeting/TargetingIndex.cs
Meta Vens f803151681 Mining index: a general targeting index fed by providers; the linear chunk scan goes
Zone.Targets (TargetingIndex) answers Within and best-first Best over
ITargetProvider regions. EntityTargets offers the observer's VisibleEntities;
BeltTargets keys each belt's rocks into kinetic bands and bounds each segment's
annular sector for distance, bearing and visibility. Entity.VisibleChunksInReach
queries the index with DetectionReachPerVisibility, an upper bound from the
sensors' hull. Zone.ChunksNear and AsteroidBelt.InnerRadius are deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:02:51 +02:00

155 lines
6.8 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.Collections.Generic;
using CultMath;
using static CultMath.math;
using float2 = CultMath.float2;
// Mining index (docs/aetheria-release-map.md, L3-L5; operator 2026-10-01: "don't specialize the indexing too much
// towards asteroid belts"): one targeting index per zone (Zone.Targets), fed by providers. It owns which targetable
// things can lie in a region and an upper bound on how visible they can be. Each provider owns its own things'
// motion and bounds. Whether one target is actually visible stays with Entity (ChunkVisible, VisibleEntities): every
// answer here is a superset the caller filters exactly. Nothing in the index names a belt or a rock.
// One thing that can be targeted, with where it stands on the plane now.
public readonly struct TargetCandidate
{
public readonly TargetRef Target;
public readonly float2 Position;
public TargetCandidate(TargetRef target, float2 position)
{
Target = target;
Position = position;
}
}
// Who is searching, from where, how far, and how well they can see. A null Observer perceives no entities.
// ReachPerVisibility is Entity.DetectionReachPerVisibility: no target of visibility v farther than v times it can be
// detected. Infinity applies no visibility bound.
public readonly struct TargetSearch
{
public readonly float2 Position;
public readonly float Reach;
public readonly Entity Observer;
public readonly float ReachPerVisibility;
public TargetSearch(float2 position, float reach, Entity observer = null, float reachPerVisibility = float.PositiveInfinity)
{
Position = position;
Reach = reach;
Observer = observer;
ReachPerVisibility = reachPerVisibility;
}
}
// A group of a provider's things, with bounds that hold for every one of them at the time it was made: planar
// distance from the searcher in [Nearest, Farthest], bearing from the searcher (radians, as atan2 measures it)
// within BearingHalfWidth of BearingCentre (pi means any bearing), and visibility at most MaxVisibility.
// Group, Start and End are the provider's own: the index only hands the region back to it.
public struct TargetRegion
{
public ITargetProvider Provider;
public int Group, Start, End;
public float Nearest, Farthest;
public float BearingCentre, BearingHalfWidth;
public float MaxVisibility;
// Whether anything in this region can both lie within reach and be detected by the searcher.
public bool CanHold(in TargetSearch search) =>
!(Nearest > search.Reach) && !(Nearest > MaxVisibility * search.ReachPerVisibility);
}
public interface ITargetProvider
{
// Regions that together hold every one of this provider's things within the search's reach, added to `into`.
void Regions(in TargetSearch search, List<TargetRegion> into);
// The things of one region this provider made for this search, with their positions now, added to `into`.
void Candidates(in TargetSearch search, in TargetRegion region, List<TargetCandidate> into);
}
// A best-first question: the candidate with the least key wins. Bound must never exceed the key of any candidate the
// region holds (positive infinity skips the region). Key returns false for a candidate that is not eligible.
// Precedes breaks a tie between two equal keys.
public interface ITargetKey
{
float Bound(in TargetRegion region);
bool Key(in TargetCandidate candidate, out float key);
bool Precedes(in TargetCandidate a, in TargetCandidate b);
}
public sealed class TargetingIndex
{
private readonly List<ITargetProvider> _providers = new List<ITargetProvider>();
private readonly List<TargetRegion> _regions = new List<TargetRegion>();
private readonly List<TargetCandidate> _candidates = new List<TargetCandidate>();
private readonly List<(float bound, int region)> _order = new List<(float, int)>();
// Diagnostic only: how many candidates the index has posed and tested, ever. Tests pin a query's cost with it
// rather than with timing.
public long Examined { get; private set; }
public void Add(ITargetProvider provider) => _providers.Add(provider);
// Every candidate within the search's reach whose region can be detected, written into `into` (cleared first),
// in no particular order. Reach is exact; detection is only bounded, so the caller still tests it.
public void Within(in TargetSearch search, List<TargetCandidate> into)
{
into.Clear();
_regions.Clear();
foreach (var provider in _providers) provider.Regions(search, _regions);
foreach (var region in _regions)
{
if (!region.CanHold(search)) continue;
_candidates.Clear();
region.Provider.Candidates(search, region, _candidates);
foreach (var candidate in _candidates)
{
Examined++;
if (length(candidate.Position - search.Position) <= search.Reach) into.Add(candidate);
}
}
}
// The eligible candidate within reach with the least key, or false if there is none. Regions are opened in order
// of their bound, and the search stops at the first region whose bound is greater than the best key found:
// Hjaltason and Samet's best-first browsing over the providers' flat region sets.
public bool Best<TKey>(in TargetSearch search, TKey query, out TargetCandidate best) where TKey : ITargetKey
{
best = default;
var found = false;
var bestKey = float.PositiveInfinity;
_regions.Clear();
_order.Clear();
foreach (var provider in _providers) provider.Regions(search, _regions);
for (var i = 0; i < _regions.Count; i++)
{
if (!_regions[i].CanHold(search)) continue;
var bound = query.Bound(_regions[i]);
if (!float.IsPositiveInfinity(bound)) _order.Add((bound, i));
}
_order.Sort((a, b) => a.bound.CompareTo(b.bound));
foreach (var (bound, index) in _order)
{
if (found && bound > bestKey) break;
_candidates.Clear();
var region = _regions[index];
region.Provider.Candidates(search, region, _candidates);
foreach (var candidate in _candidates)
{
Examined++;
if (!(length(candidate.Position - search.Position) <= search.Reach) || !query.Key(candidate, out var key)) continue;
if (!found || key < bestKey || key == bestKey && query.Precedes(candidate, best))
{
best = candidate;
bestKey = key;
found = true;
}
}
}
return found;
}
}