diff --git a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs index 7619e90fd..8eeadbb7d 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs @@ -197,4 +197,13 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer var angle = acos(dot(Direction.xz, normalize(diff))); return Gain(visibility, Evaluate(_data.Sensitivity), _data.SensitivityCurve.Evaluate(angle / PI), length(diff), 1f, false); } + + // An upper bound on sensitivity x response over every bearing, so PassiveRate(v, p) <= v x this / distance + // (Entity.DetectionReachPerVisibility). The curve lies within its control points' hull. + public float PassiveGainBound() + { + var sensitivity = Evaluate(_data.Sensitivity); + var response = _data.SensitivityCurve.HullRange(); + return max(sensitivity * response.x, sensitivity * response.y); + } } \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/BezierCurve.cs b/Assets/Scripts/ServerShared/BezierCurve.cs index 7574de8e7..656f6e448 100644 --- a/Assets/Scripts/ServerShared/BezierCurve.cs +++ b/Assets/Scripts/ServerShared/BezierCurve.cs @@ -4,6 +4,7 @@ using MessagePack; using Newtonsoft.Json; using CultMath; using static CultMath.math; +using float2 = CultMath.float2; [MessagePackObject, JsonObject(MemberSerialization.OptIn)] public class BezierCurve @@ -39,6 +40,31 @@ public class BezierCurve } } + // The least and greatest value Evaluate can return, as (min, max): each segment is a cubic Bezier whose control + // points are p0, p0 + m0/3, p1 - m1/3 and p1, and a Bezier curve lies within their hull. A stepped segment holds + // its left key; outside the keys Evaluate clamps to the end keys. A bound, not the curve's extremes. + public float2 HullRange() + { + var low = float.PositiveInfinity; + var high = float.NegativeInfinity; + void Include(float value) + { + low = min(low, value); + high = max(high, value); + } + foreach (var key in Keys) Include(key.y); + for (var i = 0; i + 1 < Keys.Length; i++) + { + var left = Keys[i]; + var right = Keys[i + 1]; + if (isinf(left.w) || isinf(right.z)) continue; + var dx = right.x - left.x; + Include(left.y + left.w * dx / 3); + Include(right.y - right.z * dx / 3); + } + return float2(low, high); + } + // Integrate area under curve between start and end time public float IntegrateCurve(float startTime, float endTime, int steps) { diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 908741173..cc5d17094 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -372,16 +372,45 @@ public abstract class Entity public bool ChunkVisible(ChunkId chunk) => ChunkInfo(chunk) > ItemManager.GameplaySettings.TargetDetectionInfoThreshold; // Mining Cut 3 (Q13 A): the reach for picking a chunk -- the longest range among this entity's active weapons - // that can mine (Weapon.CanMine, Q12 A). Every visible chunk within it, written into `into` (cleared first). - // Cycling and any programmatic chunk pick read this, so there is one reach rule. + // that can mine (Weapon.CanMine, Q12 A). Every visible chunk within it, written into `into` (cleared first), by + // index then field. Cycling and any programmatic chunk pick read this, so there is one reach rule. The zone's + // targeting index proposes the chunks that could pass (no observer, so no entities); ChunkVisible decides. public void VisibleChunksInReach(List into) { var reach = 0f; foreach (var weapon in _weapons) if (weapon.CanMine && (weapon.Item == null || weapon.Item.Active.Value)) reach = max(reach, weapon.Range); - Zone.ChunksNear(Position.xz, reach, into); - into.RemoveAll(chunk => !ChunkVisible(chunk)); + into.Clear(); + Zone.Targets.Within(new TargetSearch(Position.xz, reach, reachPerVisibility: DetectionReachPerVisibility()), _targetCandidates); + foreach (var candidate in _targetCandidates) + if (candidate.Target.Chunk is ChunkId chunk && ChunkVisible(chunk)) + into.Add(chunk); + into.Sort(ByIndexThenField); + } + + private readonly List _targetCandidates = new List(); + private static readonly Comparison ByIndexThenField = (a, b) => + a.Index != b.Index ? a.Index.CompareTo(b.Index) : a.Field.GetHashCode().CompareTo(b.Field.GetHashCode()); + + // The planar distance per unit of visibility beyond which no target can pass ChunkVisible's threshold: an upper + // bound, never a decision. ChunkInfo is saturate(sum_i v s_i c_i(angle) / d / (n k)), linear in v and falling as + // 1/d, and a Bezier curve lies within the hull of its control points, so each sensor's s_i c_i is bounded by + // Sensor.PassiveGainBound. Zero with no active sensor (nothing is visible); infinity when the threshold is not + // positive. Slack covers float rounding and only ever widens it. + public float DetectionReachPerVisibility() + { + var gain = 0f; + var sensors = 0; + foreach (var sensor in ActiveSensors()) + { + gain += sensor.PassiveGainBound(); + sensors++; + } + if (sensors == 0) return 0f; + var threshold = ItemManager.GameplaySettings.TargetDetectionInfoThreshold; + if (!(threshold > 0f)) return float.PositiveInfinity; + return max(gain, 0f) / (sensors * ItemManager.GameplaySettings.TargetInfoDecay * threshold) * 1.0001f; } // Every sensor behaviour that runs this tick: those on active equipment, and those of active consumables. diff --git a/Assets/Scripts/ServerShared/Targeting.meta b/Assets/Scripts/ServerShared/Targeting.meta new file mode 100644 index 000000000..39f43376e --- /dev/null +++ b/Assets/Scripts/ServerShared/Targeting.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7c083d5830b0418cbf8e34158465d764 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Targeting/BeltTargets.cs b/Assets/Scripts/ServerShared/Targeting/BeltTargets.cs new file mode 100644 index 000000000..6bde2c790 --- /dev/null +++ b/Assets/Scripts/ServerShared/Targeting/BeltTargets.cs @@ -0,0 +1,302 @@ +/* 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 GameCult.Caching; +using CultMath; +using static CultMath.math; +using float2 = CultMath.float2; + +// Mining index: one belt's rocks as targets. Derived from the belt's data, the zone's settings and zone time when the +// zone builds the belt, and never saved. +// +// Rocks are grouped into bands of neighbouring orbit distance. Within a band they are sorted by where they stood on +// their orbits (in turns, as AsteroidBelt.Pose measures them) at the band's key time. A rock's turn at a later time is +// its key plus elapsed / period, and a band's periods lie close together, so the rocks near a point lie in one arc of +// keys, widened by elapsed time times the band's rate spread. A band sheared past ShearLimit is re-sorted at the +// current time, at most RekeyBudget bands per search, so no single search pays for the whole belt; one that is not +// re-sorted is still searched correctly through its wider arc. Each band is split into segments of consecutive keys, +// and each segment's part of the arc is a region, bounded by the annular sector it can occupy. +public sealed class BeltTargets : ITargetProvider +{ + private const int RocksPerBand = 256; + private const int RocksPerSegment = 32; + private const double ShearLimit = 1.0 / 64; + private const int RekeyBudget = 8; + + private readonly Zone _zone; + private readonly CultRecordKey _field; + private readonly AsteroidBeltData _data; + private readonly double[] _periods; + private readonly Band[] _bands; + // No rock of the belt, whole or worn, is wider than this (AsteroidSize's bound over every argument). + private readonly float _largestRadius; + + private sealed class Band + { + public float Inner, Outer; // the least and greatest orbit distance among the band's rocks + public double SlowestRate, FastestRate; // turns per second: 1 / period + public double KeyTime; + public int[] Rocks; + public double[] Keys; // each rock's turn at KeyTime, ascending, in [0, 1) + } + + public BeltTargets(Zone zone, CultRecordKey field, AsteroidBeltData data, PlanetSettings settings, double time) + { + _zone = zone; + _field = field; + _data = data; + var size = settings.AsteroidSize; + _largestRadius = size.Exponent > 0 ? max(abs(size.Minimum), abs(size.Maximum)) : float.PositiveInfinity; + + var count = data.Asteroids.Length; + _periods = new double[count]; + var order = new int[count]; + var distances = new float[count]; + for (var i = 0; i < count; i++) + { + order[i] = i; + distances[i] = data.Asteroids[i].Distance; + // Pose divides by the float period the curve returns; the keys must use the same one. + _periods[i] = settings.OrbitPeriod.Evaluate(distances[i]); + } + Array.Sort(distances, order); + _bands = new Band[(count + RocksPerBand - 1) / RocksPerBand]; + for (var b = 0; b < _bands.Length; b++) + { + var start = b * RocksPerBand; + var length = Math.Min(RocksPerBand, count - start); + var band = new Band + { + Inner = distances[start], Outer = distances[start + length - 1], + SlowestRate = double.PositiveInfinity, FastestRate = double.NegativeInfinity, + Rocks = new int[length], Keys = new double[length] + }; + Array.Copy(order, start, band.Rocks, 0, length); + foreach (var rock in band.Rocks) + { + var rate = 1.0 / _periods[rock]; + band.SlowestRate = Math.Min(band.SlowestRate, rate); + band.FastestRate = Math.Max(band.FastestRate, rate); + } + Rekey(band, time); + _bands[b] = band; + } + } + + // Diagnostic only: how many bands have been re-sorted since the belt was built, so a test can pin the budget. + public long Rekeys { get; private set; } + + private void Rekey(Band band, double time) + { + Rekeys++; + band.KeyTime = time; + for (var j = 0; j < band.Rocks.Length; j++) + band.Keys[j] = Turn(time / _periods[band.Rocks[j]] + _data.Asteroids[band.Rocks[j]].Phase); + Array.Sort(band.Keys, band.Rocks); + } + + public void Regions(in TargetSearch search, List into) + { + if (_bands.Length == 0) return; + var time = _zone.ExactTime; + var centre = _zone.GetOrbitPosition(_zone.Orbits[_data.Orbit.Key].Data.Parent.Key); + var offset = search.Position - centre; + var distance = length(offset); + var turn = Turn(atan2(offset.y, offset.x) / (2 * PI_DBL)); + // Slack covers float rounding in Pose and in these bounds; it only ever widens a region. + var slack = 1e-4f * (_bands[_bands.Length - 1].Outer + distance) + 1e-3f; + var reach = search.Reach + slack; + var half = distance <= reach ? 1.0 : Math.Asin(reach / distance) / (2 * PI_DBL) + 1e-6; + var budget = RekeyBudget; + + // The first band whose outer edge reaches the search. + int lo = 0, hi = _bands.Length; + while (lo < hi) + { + var mid = (lo + hi) / 2; + if (_bands[mid].Outer < distance - reach) lo = mid + 1; + else hi = mid; + } + for (var b = lo; b < _bands.Length && _bands[b].Inner <= distance + reach; b++) + { + var band = _bands[b]; + if (Math.Abs(time - band.KeyTime) * (band.FastestRate - band.SlowestRate) > ShearLimit && budget > 0) + { + Rekey(band, time); + budget--; + } + Drift(band, time, out var leastDrift, out var mostDrift); + var width = 2 * half + (mostDrift - leastDrift); + if (!(width < 1)) + { + AddSegments(search, b, 0, band.Keys.Length, centre, distance, turn, slack, into); + continue; + } + // Keys whose rock can now lie within half a window of the search's own turn. + var from = Turn(turn - half - mostDrift); + var to = from + width; + AddSegments(search, b, LowerBound(band.Keys, from), UpperBound(band.Keys, Math.Min(to, 1.0)), centre, distance, turn, slack, into); + if (to > 1) AddSegments(search, b, 0, UpperBound(band.Keys, to - 1), centre, distance, turn, slack, into); + } + } + + public void Candidates(in TargetSearch search, in TargetRegion region, List into) + { + var band = _bands[region.Group]; + for (var j = region.Start; j < region.End; j++) + { + var chunk = new ChunkId(_field, band.Rocks[j]); + if (_zone.ChunkExists(chunk)) into.Add(new TargetCandidate(chunk, _zone.ChunkPose(_field, chunk.Index).xy)); + } + } + + // The keys [start, end) of one band, one region per segment they cross, each bounded by the sector its rocks can + // occupy now: the band's distances, and the turns of its first and last key widened by the band's drift. + private void AddSegments(in TargetSearch search, int b, int start, int end, float2 centre, float distance, double turn, float slack, List into) + { + var band = _bands[b]; + Drift(band, _zone.ExactTime, out var leastDrift, out var mostDrift); + var crossSection = _zone.FieldCrossSection(_data); + for (var s = start; s < end; s = (s / RocksPerSegment + 1) * RocksPerSegment) + { + var e = Math.Min(end, (s / RocksPerSegment + 1) * RocksPerSegment); + var sector = new Sector + { + Inner = band.Inner, Outer = band.Outer, + From = band.Keys[s] + leastDrift - 1e-6, + Width = band.Keys[e - 1] - band.Keys[s] + (mostDrift - leastDrift) + 2e-6 + }; + sector.Range(distance, turn, out var nearest, out var farthest); + nearest = max(0f, nearest - slack); + if (nearest > search.Reach) continue; + sector.Bearing(search.Position - centre, out var bearing, out var bearingHalf); + var light = 0f; + foreach (var sun in _zone.Suns()) + { + var fromSun = sun.Orbit.Position - centre; + sector.Range(length(fromSun), Turn(atan2(fromSun.y, fromSun.x) / (2 * PI_DBL)), out var sunNearest, out _); + light += Zone.SunLight(sun, max(0f, sunNearest - slack)); + } + var cells = _largestRadius / _zone.SchematicCellSize; + into.Add(new TargetRegion + { + Provider = this, + Group = b, Start = s, End = e, + Nearest = nearest, + Farthest = farthest + slack, + BearingCentre = bearing, + BearingHalfWidth = min((float) PI, bearingHalf + slack / max(distance, 1e-3f) + 1e-5f), + MaxVisibility = crossSection * PI * cells * cells * light * 1.0001f + }); + } + } + + private static void Drift(Band band, double time, out double least, out double most) + { + var elapsed = time - band.KeyTime; + least = Math.Min(elapsed * band.SlowestRate, elapsed * band.FastestRate); + most = Math.Max(elapsed * band.SlowestRate, elapsed * band.FastestRate); + } + + private static double Turn(double turns) => turns - Math.Floor(turns); + + private static int LowerBound(double[] keys, double value) + { + int lo = 0, hi = keys.Length; + while (lo < hi) + { + var mid = (lo + hi) / 2; + if (keys[mid] < value) lo = mid + 1; + else hi = mid; + } + return lo; + } + + private static int UpperBound(double[] keys, double value) + { + int lo = 0, hi = keys.Length; + while (lo < hi) + { + var mid = (lo + hi) / 2; + if (keys[mid] <= value) lo = mid + 1; + else hi = mid; + } + return lo; + } + + // An annular sector about the belt centre: distances [Inner, Outer], turns [From, From + Width] (a Width of 1 or + // more is the whole annulus). + private struct Sector + { + public float Inner, Outer; + public double From, Width; + + // The nearest and farthest a point `distance` from the centre at `turn` can be from any point of the sector. + public void Range(float distance, double turn, out float nearest, out float farthest) + { + double offAngle, farAngle; + if (Width >= 1) + { + offAngle = 0; + farAngle = PI_DBL; + } + else + { + var into = Turn(turn - From); + offAngle = into <= Width ? 0 : Math.Min(into - Width, 1 - into) * 2 * PI_DBL; + // The sector point farthest in angle: the antipode if the sector holds it, else the farther end. + farAngle = Turn(turn + .5 - From) <= Width + ? PI_DBL + : Math.Max(Separation(turn, From), Separation(turn, From + Width)) * 2 * PI_DBL; + } + if (offAngle == 0) + nearest = max(max(Inner - distance, distance - Outer), 0f); + else + { + // On the nearer edge ray: the closest point of the segment [Inner, Outer] along it. + var along = clamp(distance * (float) Math.Cos(offAngle), Inner, Outer); + nearest = Apart(distance, along, offAngle); + } + farthest = max(Apart(distance, Inner, farAngle), Apart(distance, Outer, farAngle)); + } + + // A circle that holds the sector, seen from `from` (relative to the belt centre): the bearing to its centre + // and the half-width of the bearings it covers (pi when `from` is inside it). + public void Bearing(float2 from, out float centre, out float halfWidth) + { + float2 middle; + float radius; + if (Width >= .5) + { + middle = float2.zero; + radius = Outer; + } + else + { + var middleTurn = (From + Width / 2) * 2 * PI_DBL; + var halfAngle = Width * PI_DBL; + var middleRadius = (Inner + Outer) / 2; + middle = middleRadius * float2((float) Math.Cos(middleTurn), (float) Math.Sin(middleTurn)); + radius = max(Apart(middleRadius, Inner, halfAngle), Apart(middleRadius, Outer, halfAngle)); + } + var toMiddle = middle - from; + var apart = length(toMiddle); + centre = atan2(toMiddle.y, toMiddle.x); + halfWidth = apart <= radius ? (float) PI : asin(radius / apart); + } + + // How far apart two points are at distances a and b from the centre, `angle` radians apart about it. + private static float Apart(float a, float b, double angle) => + (float) Math.Sqrt(Math.Max(0, (double) a * a + (double) b * b - 2.0 * a * b * Math.Cos(angle))); + + // The angle in turns between two turns, in [0, .5]. + private static double Separation(double a, double b) + { + var d = Turn(a - b); + return Math.Min(d, 1 - d); + } + } +} diff --git a/Assets/Scripts/ServerShared/Targeting/BeltTargets.cs.meta b/Assets/Scripts/ServerShared/Targeting/BeltTargets.cs.meta new file mode 100644 index 000000000..853bba9c2 --- /dev/null +++ b/Assets/Scripts/ServerShared/Targeting/BeltTargets.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d33fc585810446f7b0a9319196c74633 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Targeting/EntityTargets.cs b/Assets/Scripts/ServerShared/Targeting/EntityTargets.cs new file mode 100644 index 000000000..f4103e6d0 --- /dev/null +++ b/Assets/Scripts/ServerShared/Targeting/EntityTargets.cs @@ -0,0 +1,39 @@ +/* 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 static CultMath.math; + +// Mining index: entities as targets. Perception stays Entity's: the only entities a search can be offered are those +// already in its observer's VisibleEntities, so the one region carries no visibility bound (they are already seen). +public sealed class EntityTargets : ITargetProvider +{ + public void Regions(in TargetSearch search, List into) + { + var observer = search.Observer; + if (observer == null || observer.VisibleEntities.Count == 0) return; + var nearest = float.PositiveInfinity; + var farthest = 0f; + foreach (var entity in observer.VisibleEntities) + { + var distance = length(entity.Position.xz - search.Position); + nearest = min(nearest, distance); + farthest = max(farthest, distance); + } + into.Add(new TargetRegion + { + Provider = this, + Nearest = nearest, + Farthest = farthest, + BearingHalfWidth = PI, + MaxVisibility = float.PositiveInfinity + }); + } + + public void Candidates(in TargetSearch search, in TargetRegion region, List into) + { + foreach (var entity in search.Observer.VisibleEntities) + into.Add(new TargetCandidate(entity, entity.Position.xz)); + } +} diff --git a/Assets/Scripts/ServerShared/Targeting/EntityTargets.cs.meta b/Assets/Scripts/ServerShared/Targeting/EntityTargets.cs.meta new file mode 100644 index 000000000..49bb99318 --- /dev/null +++ b/Assets/Scripts/ServerShared/Targeting/EntityTargets.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1db47cd187504dbb97994e4b8c436ef1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Targeting/TargetingIndex.cs b/Assets/Scripts/ServerShared/Targeting/TargetingIndex.cs new file mode 100644 index 000000000..62f5cf6da --- /dev/null +++ b/Assets/Scripts/ServerShared/Targeting/TargetingIndex.cs @@ -0,0 +1,155 @@ +/* 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 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 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 _providers = new List(); + private readonly List _regions = new List(); + private readonly List _candidates = new List(); + 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 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(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; + } +} diff --git a/Assets/Scripts/ServerShared/Targeting/TargetingIndex.cs.meta b/Assets/Scripts/ServerShared/Targeting/TargetingIndex.cs.meta new file mode 100644 index 000000000..4b40c10b0 --- /dev/null +++ b/Assets/Scripts/ServerShared/Targeting/TargetingIndex.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9fef7cc648f2498ebd24e1d0ca61b5c3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 9b9e409e7..863061eec 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -23,6 +23,12 @@ public class Zone public Dictionary AsteroidBelts = new Dictionary(); public PlanetSettings Settings; + // Mining index (docs/aetheria-release-map.md, L3-L5): every targetable thing in the zone, fed by providers -- + // the entities, and one provider per belt. The reticle, cycling and any range query over rocks go through it; + // nothing else scans a belt. + public readonly TargetingIndex Targets = new TargetingIndex(); + private readonly List _suns = new List(); + private HashSet _updatedOrbits = new HashSet(); private ItemManager _itemManager; @@ -62,6 +68,9 @@ public class Zone { get => (float) _time; } + + // Zone time at full precision, for the index's orbital keys, which must agree with ChunkPose's own. + internal double ExactTime => _time; public ZonePack Pack { get; } public GalaxyZone GalaxyZone { get; } public Galaxy Galaxy { get; } @@ -89,6 +98,8 @@ public class Zone // ObserveAdd fires for all of them. Forbidden writer: no presentation may remove an entity from Zone. Entities.ObserveAdd().Subscribe(add => add.Value.Death.Subscribe(_ => { Entities.Remove(add.Value); add.Value.Deactivate(); })); + Targets.Add(new EntityTargets()); + foreach (var orbit in pack.Orbits) { Orbits.Add(orbit.Key, new Orbit(Settings, cache.Get(orbit))); @@ -105,9 +116,12 @@ public class Zone // the rule generation uses, and keeps it. FieldKinds.Ensure(body.Key, belt, cache); AsteroidBelts[body.Key] = new AsteroidBelt(belt); + Targets.Add(new BeltTargets(this, body.Key, belt, settings, _time)); break; case SunData sun: - PlanetInstances.Add(body.Key, new Sun(settings, sun, Orbits[planet.Orbit.Key])); + var sunInstance = new Sun(settings, sun, Orbits[planet.Orbit.Key]); + PlanetInstances.Add(body.Key, sunInstance); + _suns.Add(sunInstance); break; case GasGiantData gas: PlanetInstances.Add(body.Key, new GasGiant(settings, gas, Orbits[planet.Orbit.Key])); @@ -320,25 +334,6 @@ public class Zone return kind.CrossSection * PI * cells * cells * GetLight(ChunkPose(chunk.Field, chunk.Index).xy); } - // Mining Cut 3: the one chunk query -- every chunk that exists and whose centre lies within `range` of - // `position`, written into `into` (cleared first). Belts whose annulus cannot reach are skipped whole. It knows - // nothing about detection; callers filter by what an observer can see. - public void ChunksNear(float2 position, float range, List into) - { - into.Clear(); - foreach (var belt in AsteroidBelts) - { - var centre = GetOrbitPosition(Orbits[belt.Value.Data.Orbit.Key].Data.Parent.Key); - var fromCentre = length(position - centre); - if (fromCentre - range > belt.Value.Radius || fromCentre + range < belt.Value.InnerRadius) continue; - for (var i = 0; i < belt.Value.Data.Asteroids.Length; i++) - { - var chunk = new ChunkId(belt.Key, i); - if (ChunkExists(chunk) && length(ChunkPose(belt.Key, i).xy - position) <= range) into.Add(chunk); - } - } - } - public void EvaluateBelt(CultRecordKey belt, Span into) { var beltData = Planets[belt] as AsteroidBeltData; @@ -418,23 +413,20 @@ public class Zone public float GetLight(float2 position) { var light = 0f; - foreach (var body in PlanetInstances.Values) - { - if (body is Sun sun) - { - var p = position - body.Orbit.Position; - var distSqr = lengthsq(p); - var lightRadius = sun.LightRadius; - if (distSqr < lightRadius * lightRadius) - { - light += PowerPulse(sqrt(distSqr) / lightRadius, 8); - } - } - } - + foreach (var sun in _suns) + light += SunLight(sun, length(position - sun.Orbit.Position)); return light; } + // One sun's light at `distance` from it. It never grows with distance, so the light at the nearest point of a + // region bounds the light anywhere in it (BeltTargets' visibility bound). + public static float SunLight(Sun sun, float distance) => + distance < sun.LightRadius ? PowerPulse(distance / sun.LightRadius, 8) : 0f; + + internal IReadOnlyList Suns() => _suns; + internal float SchematicCellSize => _itemManager.GameplaySettings.SchematicCellSize; + internal float FieldCrossSection(AsteroidBeltData belt) => _itemManager.ItemData.Get(belt.Kind)?.CrossSection ?? 0f; + public float2 GetForce(float2 position) { var normal = GetNormal(position); @@ -537,14 +529,11 @@ public class AsteroidBelt { public AsteroidBeltData Data; public float Radius { get; } - // Mining Cut 3: the belt's inner edge, so the chunk query can skip a belt whose annulus it cannot reach. - public float InnerRadius { get; } public AsteroidBelt(AsteroidBeltData data) { Data = data; Radius = data.Asteroids.Max(a => a.Distance); - InnerRadius = data.Asteroids.Min(a => a.Distance); } // Cut 1 (docs/mining-cut.md): the formula of Zone.cs:269-271 verbatim, `time` in double as `_time` was used.