diff --git a/Assets/Resources/Settings.asset b/Assets/Resources/Settings.asset index b2a9b0ed2..692fb08fe 100644 --- a/Assets/Resources/Settings.asset +++ b/Assets/Resources/Settings.asset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:347752dd66c3d250eddde8f6f78a17d7369d562bd698a0c8099e941f7c0825c8 -size 13163 +oid sha256:b2e346f533ccee5d2c655b3d8e0817e3f93e9234af06557ee9c6ec13cecb9acd +size 13137 diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 422ad2327..c8f0bb6e2 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -377,53 +377,26 @@ public class ActionGameManager : MonoBehaviour // reads as non-hostile here, so the first press declares hostile. Input.Player.ToggleStance.performed += context => { - var target = CurrentEntity.Target.Value; + var target = CurrentEntity.Target.Value.Entity; if (target == null) return; CurrentEntity.SetIff(target, !CurrentEntity.IsHostileTo(target)); }; #region Targeting - Input.Player.TargetReticle.performed += context => - { - if (!CurrentEntity.VisibleEntities.Any()) return; - var underReticle = CurrentEntity.VisibleEntities.Where(x => x != CurrentEntity) - .MaxBy(x => dot(normalize(x.Position - CurrentEntity.Position), CurrentEntity.LookDirection)); - CurrentEntity.Target.Value = CurrentEntity.Target.Value == underReticle ? null : underReticle; - }; - - // Nearest is enemies-only (it drives weapon lock), and picks the closest target, not the farthest. - Input.Player.TargetNearest.performed += context => - { - if(CurrentEntity.VisibleEnemies.Any()) - { - CurrentEntity.Target.Value = CurrentEntity.VisibleEnemies.Where(x => x != CurrentEntity) - .MinBy(x => length(x.Position - CurrentEntity.Position)); - } - }; - - Input.Player.TargetNext.performed += context => - { - if (!CurrentEntity.VisibleEntities.Any()) return; - var targets = CurrentEntity.VisibleEntities.Where(x => x != CurrentEntity).OrderBy(x => length(x.Position - CurrentEntity.Position)).ToArray(); - var currentTargetIndex = Array.IndexOf(targets, CurrentEntity.Target.Value); - CurrentEntity.Target.Value = targets[(currentTargetIndex + 1) % targets.Length]; - }; - - Input.Player.TargetPrevious.performed += context => - { - if (!CurrentEntity.VisibleEntities.Any()) return; - var targets = CurrentEntity.VisibleEntities.Where(x => x != CurrentEntity).OrderBy(x => length(x.Position - CurrentEntity.Position)).ToArray(); - var currentTargetIndex = Array.IndexOf(targets, CurrentEntity.Target.Value); - CurrentEntity.Target.Value = targets[(currentTargetIndex + targets.Length - 1) % targets.Length]; - }; + // The reticle, nearest, next and previous presses each choose through Entity (mining target-queries cut): + // the rule lives with the entity, so the player, the test agent and any director pick alike. + Input.Player.TargetReticle.performed += context => CurrentEntity.TargetUnderReticle(); + Input.Player.TargetNearest.performed += context => CurrentEntity.TargetNearestEnemy(); + Input.Player.TargetNext.performed += context => CurrentEntity.TargetNext(); + Input.Player.TargetPrevious.performed += context => CurrentEntity.TargetPrevious(); // Cut 2 (docs/fire-control-cut.md): cycles the aim point among the current target's revealed // subsystems -- decides nothing itself, only calls the one writer (TrySelectTargetItem), same - // predicate (FireControl.IsRevealed) the AI path uses. + // predicate (FireControl.IsRevealed) the AI path uses. Entity-only: a chunk has no subsystems. Input.Player.CycleTargetItem.performed += context => { - var target = CurrentEntity.Target.Value; + var target = CurrentEntity.Target.Value.Entity; if (target == null) return; var revealed = target.Equipment.Where(x => FireControl.IsRevealed(CurrentEntity, x)).ToArray(); if (revealed.Length == 0) @@ -596,7 +569,7 @@ public class ActionGameManager : MonoBehaviour ConsoleController.AddCommand("iff", args => { var console = ConsoleController.Instance; - var target = _currentEntity?.Target.Value; + var target = _currentEntity?.Target.Value.Entity; if (target == null) { console.AppendLogLine("iff: no target selected"); return; } var mode = args.Length > 0 ? args[0] : ""; if (mode != "hostile" && mode != "neutral" && mode != "clear") @@ -1096,9 +1069,10 @@ public class ActionGameManager : MonoBehaviour _targetSubscriptions.Clear(); UpdateTargetPanel(target); - if (target != null) + var targetEntity = target.Entity; + if (targetEntity != null) { - if (target.Shield != null) + if (targetEntity.Shield != null) { TargetShieldsBackground.color = new Color(ShieldColor.r, ShieldColor.g, ShieldColor.b, .4f); TargetShieldsIcon.color = ShieldColor; @@ -1112,7 +1086,7 @@ public class ActionGameManager : MonoBehaviour } // Subscribe to incoming hits from the player ship to display the hit marker - _targetSubscriptions.Add(target.IncomingHit.Where(e => e == CurrentEntity).Subscribe(_ => + _targetSubscriptions.Add(targetEntity.IncomingHit.Where(e => e == CurrentEntity).Subscribe(_ => { HitMarker.SetActive(true); _hitMarkerTime = HitMarkerDuration; @@ -1172,17 +1146,25 @@ public class ActionGameManager : MonoBehaviour SchematicDisplay.ShowShip(CurrentEntity); } - private void UpdateTargetPanel(Entity target) + // Mining Cut 3: any target gets the indicator; only a ship target gets the ship panel. + private void UpdateTargetPanel(TargetRef target) { - TargetIndicator.gameObject.SetActive(target != null); - TargetShipPanel.gameObject.SetActive(target != null); - if (target != null) + TargetIndicator.gameObject.SetActive(!target.IsNone); + TargetShipPanel.gameObject.SetActive(target.Entity != null); + if (target.Entity != null) { - TargetShipPanel.Display(target, true); - TargetSchematicDisplay.ShowShip(target, CurrentEntity); + TargetShipPanel.Display(target.Entity, true); + TargetSchematicDisplay.ShowShip(target.Entity, CurrentEntity); } } + // A chunk's simulated position is planar (Zone.ChunkPose); the renderer draws it at the asteroid layer's height. + private float3 ChunkWorldPosition(ChunkId chunk) + { + var pose = CurrentEntity.Zone.ChunkPose(chunk.Field, chunk.Index); + return float3(pose.x, Settings.PlanetSettings.AsteroidVerticalOffset, pose.y); + } + private void Die(CauseOfDeath cause) { var deathTime = Time.time; @@ -1266,7 +1248,7 @@ public class ActionGameManager : MonoBehaviour { foreach (var indicator in _visibleHostileIndicators) { - indicator.Value.gameObject.SetActive(indicator.Key!=CurrentEntity.Target.Value); + indicator.Value.gameObject.SetActive(indicator.Key!=CurrentEntity.Target.Value.Entity); indicator.Value.Place.Target = indicator.Key.Position.ToUnity(); if (!indicator.Key.Active) indicator.Value.Fill.enabled = false; @@ -1281,7 +1263,7 @@ public class ActionGameManager : MonoBehaviour } foreach (var indicator in _visibleFriendlyIndicators) { - indicator.Value.gameObject.SetActive(indicator.Key!=CurrentEntity.Target.Value); + indicator.Value.gameObject.SetActive(indicator.Key!=CurrentEntity.Target.Value.Entity); indicator.Value.Place.Target = indicator.Key.Position.ToUnity(); if (!indicator.Key.Active) indicator.Value.Fill.enabled = false; @@ -1308,8 +1290,8 @@ public class ActionGameManager : MonoBehaviour ship.MovementDirection = Input.Player.Move.ReadValue().ToCultMath(); } - var target = CurrentEntity.Target.Value; - UpdateFireControlDebug(target); + UpdateFireControlDebug(CurrentEntity.Target.Value); + var target = CurrentEntity.Target.Value.Entity; if (target != null) { var threshold = Settings.GameplaySettings.TargetDetectionInfoThreshold; @@ -1334,7 +1316,7 @@ public class ActionGameManager : MonoBehaviour _ => "MISS" }; - private void UpdateFireControlDebug(Entity target) + private void UpdateFireControlDebug(TargetRef target) { if (DebugInfoText == null) return; @@ -1355,7 +1337,8 @@ public class ActionGameManager : MonoBehaviour return; } - var d = FireControl.Inspect(selectedWeapon, CurrentEntity, target); + // Mining Cut 3: fire control prices ships; a chunk target reads as none there until Cut 4. + var d = FireControl.Inspect(selectedWeapon, CurrentEntity, target.Entity); var pendingLine = "pending: none"; for (var i = CurrentEntity.Zone.PendingShots.Count - 1; i >= 0; i--) { @@ -1378,7 +1361,8 @@ public class ActionGameManager : MonoBehaviour var lastLine = _debugLastShots.TryGetValue(selectedItem, out var lastShot) ? $"last {lastShot.ShotId}: {ResultLabel(lastShot.Result)} cell {lastShot.Cell.x},{lastShot.Cell.y}" : "last: none"; - var gates = target == null + var gates = target.Chunk.HasValue ? "target: chunk" + : target.Entity == null ? "target: none" : $"gates designated {d.Designated} arc {d.InArc}"; @@ -1409,8 +1393,11 @@ public class ActionGameManager : MonoBehaviour if (CurrentEntity == null || CurrentEntity.Parent != null) return; ViewDot.Target = ZoneRenderer.EntityInstances[CurrentEntity].LookAtPoint.position; - if (CurrentEntity.Target.Value != null) - TargetIndicator.Target = CurrentEntity.Target.Value.Position.ToUnity(); + var target = CurrentEntity.Target.Value; + if (target.Chunk is ChunkId chunk) + TargetIndicator.Target = ChunkWorldPosition(chunk).ToUnity(); + else if (target.Entity != null) + TargetIndicator.Target = target.Entity.Position.ToUnity(); var distance = length(ViewDot.Target.ToCultMath() - CurrentEntity.Position); foreach (var (_, barrels, crosshair) in _articulationGroups) { @@ -1423,11 +1410,11 @@ public class ActionGameManager : MonoBehaviour foreach (var (targetLock, indicator, spin) in _lockingIndicators) { - var showLockingIndicator = targetLock.Lock > .01f && CurrentEntity.Target.Value != null && CurrentEntity.IsHostileTo(CurrentEntity.Target.Value); + var showLockingIndicator = targetLock.Lock > .01f && CurrentEntity.Target.Value.Entity != null && CurrentEntity.IsHostileTo(CurrentEntity.Target.Value.Entity); indicator.gameObject.SetActive(showLockingIndicator); if(showLockingIndicator) { - indicator.Target = CurrentEntity.Target.Value.Position.ToUnity(); + indicator.Target = CurrentEntity.Target.Value.Entity.Position.ToUnity(); indicator.NoiseAmplitude = Settings.GameplaySettings.LockIndicatorNoiseAmplitude * (1 - targetLock.Lock); indicator.NoiseFrequency = Settings.GameplaySettings.LockIndicatorFrequency.Evaluate(targetLock.Lock); spin.Speed = Settings.GameplaySettings.LockSpinSpeed.Evaluate(targetLock.Lock); diff --git a/Assets/Scripts/Gameplay/EntityInstance.cs b/Assets/Scripts/Gameplay/EntityInstance.cs index 09d5b6393..31b6d093c 100644 --- a/Assets/Scripts/Gameplay/EntityInstance.cs +++ b/Assets/Scripts/Gameplay/EntityInstance.cs @@ -235,7 +235,7 @@ public class EntityInstance : MonoBehaviour } constantWeapon.OnStartFiring += () => - _constantWeaponManagers[data].StartFiring(data, item, this, entity.Target.Value != null ? ZoneRenderer.EntityInstances[entity.Target.Value] : null); + _constantWeaponManagers[data].StartFiring(data, item, this, entity.Target.Value.Entity != null ? ZoneRenderer.EntityInstances[entity.Target.Value.Entity] : null); constantWeapon.OnStopFiring += () => _constantWeaponManagers[data].StopFiring(item); } @@ -399,7 +399,7 @@ public class EntityInstance : MonoBehaviour } LookAtPoint.position = transform.position + Entity.LookDirection.ToUnity() * - (Entity.Target.Value != null ? max(Entity.TargetRange,Entity.ItemManager.GameplaySettings.ConvergenceMinimumDistance) : 10000); + (!Entity.Target.Value.IsNone ? max(Entity.TargetRange,Entity.ItemManager.GameplaySettings.ConvergenceMinimumDistance) : 10000); LocalSpace.localPosition = transform.position = Entity.Position.ToUnity(); if (_influenceInstance) _influenceInstance.position = new Vector3(Entity.Position.x, 0, Entity.Position.z); diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index 8f0816ef9..f265151c9 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -6,7 +6,7 @@ using GameCult.Caching.MessagePack; // Aetheria's stores: one home store per type, composed here and nowhere else. public static class AetheriaStores { - public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(ShipAuthoring), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(NameFile), typeof(InputLayout), typeof(Loadout) }; + public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(ShipAuthoring), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(NameFile), typeof(InputLayout), typeof(Loadout), typeof(FieldKindData) }; public static readonly Type[] RunTypes = { typeof(OrbitData), typeof(BodyData), typeof(SavedZone), typeof(SavedGame), typeof(ProvenanceLedger) }; public static readonly Type[] PlayerTypes = { typeof(PlayerSettings) }; diff --git a/Assets/Scripts/ServerShared/Agents/Minion.cs b/Assets/Scripts/ServerShared/Agents/Minion.cs index 28145c504..7540cb5c8 100644 --- a/Assets/Scripts/ServerShared/Agents/Minion.cs +++ b/Assets/Scripts/ServerShared/Agents/Minion.cs @@ -11,13 +11,13 @@ public class Minion : Agent () => Task is PatrolOrbitsTask, () => patrolState.Task = Task as PatrolOrbitsTask); - Ship.VisibleEnemies.ObserveAdd().Where(_ => Ship.Target.Value == null).Subscribe(add => Ship.Target.Value = add.Value); + Ship.VisibleEnemies.ObserveAdd().Where(_ => Ship.Target.Value.IsNone).Subscribe(add => Ship.SetTarget(add.Value)); var combatState = new CombatState(this); _rootState.AddTransition(combatState, - () => Ship.Target.Value != null, null, true, _rootState); + () => Ship.Target.Value.Entity != null, null, true, _rootState); combatState.AddTransition(_rootState, - () => Ship.Target.Value == null, null, true, _rootState); + () => Ship.Target.Value.Entity == null, null, true, _rootState); } } \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index d605ff8a5..c93fb3c99 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -25,7 +25,7 @@ public class CombatState : BaseState public override void Update(float delta) { - var target = _agent.Ship.Target.Value; + var target = _agent.Ship.Target.Value.Entity; if (target == null) return; _availableGroups.Clear(); diff --git a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs index b9c4c152e..e1d0d8cea 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs @@ -170,7 +170,7 @@ public interface IPowerConsumer Union(23, typeof(ThermotoggleData)), Union(24, typeof(EnergyDrawData)), // Union(25, typeof(MiningControllerData)), - Union(26, typeof(MiningToolData)), + // Union(26): retired, do not reuse -- was the "hit it with a tool" behavior deleted in Cut 2 (docs/mining-cut.md, Q1=A). // Union(27, typeof(SurveyControllerData)), Union(28, typeof(ResourceScannerData)), // Union(29, typeof(WanderControllerData)), diff --git a/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs index 7499ab6f8..6b88f4ba4 100644 --- a/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs @@ -65,6 +65,9 @@ public class LockWeapon : InstantWeapon // before a shot is ever attempted. public bool IsLocked => _lock > .99f; + // Q12 A: a launcher cannot mine. + public override bool CanMine => false; + public LockWeapon(LockWeaponData data, EquippedItem item) : base(data, item) { @@ -77,13 +80,16 @@ public class LockWeapon : InstantWeapon public override bool Execute(float dt) { - if (_target != Entity.Target.Value) + // Mining Cut 3 (Q12 A): lock builds on an entity only. A chunk target reads as no target here, so a + // launcher never locks a rock. + var target = Entity.Target.Value.Entity; + if (_target != target) { _lock = 0; - _target = Entity.Target.Value; + _target = target; } - if (Entity.Target.Value != null && Entity.IsHostileTo(Entity.Target.Value)) + if (target != null && Entity.IsHostileTo(target)) { LockSpeed = Evaluate(_data.LockSpeed); SensorImpact = Evaluate(_data.SensorImpact); @@ -91,11 +97,11 @@ public class LockWeapon : InstantWeapon DirectionImpact = Evaluate(_data.DirectionImpact); Decay = Evaluate(_data.Decay); - var degrees = acos(dot(normalize(Entity.Target.Value.Position - Entity.Position), normalize(Entity.LookDirection))) * 57.2958f; + var degrees = acos(dot(normalize(target.Position - Entity.Position), normalize(Entity.LookDirection))) * 57.2958f; if (degrees < LockAngle) { var lerp = 1 - unlerp(0, 90, degrees); - _lock = saturate(_lock + pow(lerp, DirectionImpact) * dt * LockSpeed * pow(Entity.EntityInfoGathered[Entity.Target.Value], SensorImpact)); + _lock = saturate(_lock + pow(lerp, DirectionImpact) * dt * LockSpeed * pow(Entity.EntityInfoGathered[target], SensorImpact)); } else _lock = saturate(_lock - dt * Decay); } diff --git a/Assets/Scripts/ServerShared/Behaviors/MiningTool.cs b/Assets/Scripts/ServerShared/Behaviors/MiningTool.cs deleted file mode 100644 index f03c12fac..000000000 --- a/Assets/Scripts/ServerShared/Behaviors/MiningTool.cs +++ /dev/null @@ -1,76 +0,0 @@ -/* 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 GameCult.Caching; -using System; -using System.Linq; -using MessagePack; -using Newtonsoft.Json; -using CultMath; -using static CultMath.math; - -[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)] -public class MiningToolData : BehaviorData -{ - [Inspectable, JsonProperty("dps"), Key(1)] - public PerformanceStat DamagePerSecond = new PerformanceStat(); - - [Inspectable, JsonProperty("efficiency"), Key(2)] - public PerformanceStat Efficiency = new PerformanceStat(); - - [Inspectable, JsonProperty("penetration"), Key(3)] - public PerformanceStat Penetration = new PerformanceStat(); - - [Inspectable, JsonProperty("range"), Key(4)] - public PerformanceStat Range = new PerformanceStat(); - - public override Behavior CreateInstance(EquippedItem item) - { - return new MiningTool(this, item); - } - public override Behavior CreateInstance(ConsumableItemEffect item) - { - return new MiningTool(this, item); - } -} - -public class MiningTool : Behavior -{ - public CultRecordKey AsteroidBelt; - public int Asteroid; - - private MiningToolData _data; - public float Range { get; private set; } - - public MiningTool(MiningToolData data, EquippedItem item) : base(data, item) - { - _data = data; - } - - public MiningTool(MiningToolData data, ConsumableItemEffect item) : base(data, item) - { - _data = data; - } - - public override bool Execute(float dt) - { - Range = Evaluate(_data.Range); - var belt = Entity.Zone.AsteroidBelts[AsteroidBelt]; - if (AsteroidBelt.IsSet() && - Entity.Zone.AsteroidExists(AsteroidBelt, Asteroid) && - length(Entity.Position.xz - belt.Transforms[Asteroid].xy) - belt.Transforms[Asteroid].w < Range) - { - Entity.Zone.MineAsteroid( - Entity, - AsteroidBelt, - Asteroid, - Evaluate(_data.DamagePerSecond) * dt, - Evaluate(_data.Efficiency), - Evaluate(_data.Penetration)); - return true; - } - - return false; - } -} \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/Behaviors/ResourceScanner.cs b/Assets/Scripts/ServerShared/Behaviors/ResourceScanner.cs index 0b708cd96..e54fa4a9f 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ResourceScanner.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ResourceScanner.cs @@ -2,13 +2,8 @@ * 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 GameCult.Caching; -using System; -using System.Linq; using MessagePack; using Newtonsoft.Json; -using CultMath; -using static CultMath.math; [Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable] public class ResourceScannerData : BehaviorData @@ -33,83 +28,18 @@ public class ResourceScannerData : BehaviorData } } -public class ResourceScanner : Behavior, IAlwaysUpdatedBehavior +// Cut 2 (docs/mining-cut.md, Q1=A): the dead survey Execute and its scan-target fields are gone +// (ScanTarget/Asteroid/_scanTime/_scanTarget, and the always-false-until-scanned PlanetSurveyFloor write it +// worked toward). Mining Cut 3 (docs/mining-cut-refresh.md): chunks are detected by reflected light, not by a +// scanner, so the per-tick evaluation of three stats nothing read is gone too. The behavior is parked: its data +// stays authored, and it does nothing. +public class ResourceScanner : Behavior { - public int Asteroid = -1; - - private ResourceScannerData _data; - private float _scanTime; - private CultRecordKey _scanTarget; - - public float Range { get; private set; } - public float MinimumDensity { get; private set; } - public float ScanDuration { get; private set; } - - public CultRecordKey ScanTarget - { - get => _scanTarget; - set - { - if (!value.Equals(_scanTarget)) - { - _scanTarget = value; - _scanTime = 0; - } - } - } - public ResourceScanner(ResourceScannerData data, EquippedItem item) : base(data, item) { - _data = data; } public ResourceScanner(ResourceScannerData data, ConsumableItemEffect item) : base(data, item) { - _data = data; - } - - public override bool Execute(float dt) - { - var planetData = Entity.Zone.Planets[ScanTarget]; - if (planetData != null) - { - if (planetData is AsteroidBeltData beltData) - { - if(Asteroid > -1 && - Asteroid < beltData.Asteroids.Length && - length(Entity.Position.xz - Entity.Zone.AsteroidBelts[ScanTarget].Transforms[Asteroid].xy) < Range) - { - _scanTime += dt; - if (_scanTime > ScanDuration) - { - // TODO: Implement Scanning! - //Context.ItemData.Get(Entity.Corporation).PlanetSurveyFloor[ScanTarget] = MinimumDensity; - _scanTime = 0; - } - return true; - } - } - else - { - if(length(Entity.Position.xz - Entity.Zone.GetOrbitPosition(planetData.Orbit.Key)) < Range) - { - _scanTime += dt; - if (_scanTime > ScanDuration) - { - //Context.ItemData.Get(Entity.Corporation).PlanetSurveyFloor[ScanTarget] = MinimumDensity; - _scanTime = 0; - } - return true; - } - } - } - return false; - } - - public void Update(float delta) - { - Range = Evaluate(_data.Range); - MinimumDensity = Evaluate(_data.MinimumDensity); - ScanDuration = Evaluate(_data.ScanDuration); } } \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs index 228657b5d..8eeadbb7d 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs @@ -153,35 +153,24 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer _pingCooldown -= dt / Evaluate(_data.PingCooldown); - // TODO: Handle Active Detection / Visibility From Reflected Radiance var forward = Direction.xz; foreach (var entity in Entity.Zone.Entities) { if (entity == Entity) continue; - + var diff = entity.Position.xz - Entity.Position.xz; - var angle = acos(dot(forward, normalize(diff))); var dist = length(diff); float previous, next; Entity.EntityInfoGathered.TryGetValue(entity, out previous); if (!_pingedEntities.Contains(entity) && dist < _pingRadius) { _pingedEntities.Add(entity); - next = saturate( - previous + - entity.Visibility * - Evaluate(_data.Sensitivity) * - Evaluate(_data.PingBoost) * - dist); + next = saturate(previous + Gain(entity.Visibility, Evaluate(_data.Sensitivity), Evaluate(_data.PingBoost), dist, dt, true)); } else { - next = saturate( - previous + - entity.Visibility * - Evaluate(_data.Sensitivity) * - _data.SensitivityCurve.Evaluate(angle / PI) * - dt / dist); + var angle = acos(dot(forward, normalize(diff))); + next = saturate(previous + Gain(entity.Visibility, Evaluate(_data.Sensitivity), _data.SensitivityCurve.Evaluate(angle / PI), dist, dt, false)); } next *= 1 - ItemManager.GameplaySettings.TargetInfoDecay * dt; //Context.Log($"{entity.Name} visibility {(int)(previous * 100)}% -> {(int)(next * 100)}%"); @@ -189,4 +178,32 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer } return true; } + + // Mining Cut 3 (docs/mining-cut-refresh.md): the one detection gain rule -- the info one observation of a + // target adds. A ping adds `visibility × sensitivity × PingBoost × distance` once; a passive tick adds + // `visibility × sensitivity × curve(angle/π) × dt / distance`. `response` is PingBoost for a ping and the + // sensitivity curve at the bearing for a passive tick. The entity loop above and the chunk query + // (Entity.ChunkInfo, through PassiveRate) both call this; nothing else multiplies these terms. + public static float Gain(float visibility, float sensitivity, float response, float distance, float dt, bool ping) => + ping + ? visibility * sensitivity * response * distance + : visibility * sensitivity * response * dt / distance; + + // The passive gain per second this sensor draws from a target of `visibility` at the planar `position`: + // Gain over one second, with the bearing measured from this sensor's own facing exactly as Execute does. + public float PassiveRate(float visibility, float2 position) + { + var diff = position - Entity.Position.xz; + 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/Behaviors/TurretController.cs b/Assets/Scripts/ServerShared/Behaviors/TurretController.cs index 48b4c6c4e..a5a395373 100644 --- a/Assets/Scripts/ServerShared/Behaviors/TurretController.cs +++ b/Assets/Scripts/ServerShared/Behaviors/TurretController.cs @@ -57,16 +57,18 @@ public class TurretController : Behavior, IInitializableBehavior public override bool Execute(float dt) { - if (Entity.Target.Value != null) + // Mining Cut 3: a turret engages entities only; it never picks a chunk, and reads one as no target. + var target = Entity.Target.Value.Entity; + if (target != null) { - var diff = Entity.Target.Value.Position - Entity.Position; + var diff = target.Position - Entity.Position; if (_predictShots) { - var targetHullData = Entity.ItemManager.GetData(Entity.Target.Value.Hull) as HullData; - var targetVelocity = float3(Entity.Target.Value.Velocity.x, 0, Entity.Target.Value.Velocity.y); + var targetHullData = Entity.ItemManager.GetData(target.Hull) as HullData; + var targetVelocity = float3(target.Velocity.x, 0, target.Velocity.y); var predictedPosition = first_order_intercept( Entity.Position, float3.zero, _shotSpeed, - Entity.Target.Value.Position, targetVelocity + target.Position, targetVelocity ); predictedPosition.y = Entity.Zone.GetHeight(predictedPosition.xz) + targetHullData.GridOffset; Entity.LookDirection = normalize(predictedPosition - Entity.Position); @@ -78,7 +80,7 @@ public class TurretController : Behavior, IInitializableBehavior { // The same per-weapon decision Combat.cs makes; the range test is part of it (a shot out of range // is not designated and prices at zero). - if (FireControl.AgentFires(x, Entity, Entity.Target.Value)) + if (FireControl.AgentFires(x, Entity, target)) { x.Activate(); } @@ -93,7 +95,7 @@ public class TurretController : Behavior, IInitializableBehavior if (x.Firing) x.Deactivate(); } - Entity.Target.Value = Entity.VisibleEnemies.FirstOrDefault(e => e is Ship); + Entity.SetTarget(Entity.VisibleEnemies.FirstOrDefault(e => e is Ship)); } return true; } diff --git a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs index 47f2acf11..450490cfe 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs @@ -88,6 +88,11 @@ public abstract class Weapon : Behavior, IActivatedBehavior public float Spread { get; protected set; } public float Velocity { get; protected set; } + // Mining Cut 3 fix (Q12 A, "launchers cannot mine"): whether this weapon can mine a chunk. The one mining + // predicate: chunk reach (Entity.VisibleChunksInReach) reads it, and so does anything that decides a weapon + // may work a chunk. A launcher overrides it. + public virtual bool CanMine => true; + protected bool _firing; public bool Firing @@ -97,8 +102,9 @@ public abstract class Weapon : Behavior, IActivatedBehavior // Single shared fire gate for player and AI shooters alike: weapons are safed by the // shooter's OWN declared stance toward its target, not the target's stance toward the shooter. - // With no target set, behaviour is unchanged (nothing to be safe about). - public bool StanceAllowsFire => Entity.Target.Value == null || Entity.IsHostileTo(Entity.Target.Value); + // With no target set, behaviour is unchanged (nothing to be safe about). Mining Cut 3: a chunk has no stance, + // so a weapon is never safed against one. + public bool StanceAllowsFire => Entity.Target.Value.Entity == null || Entity.IsHostileTo(Entity.Target.Value.Entity); // Cut 3 (docs/fire-control-cut.md, Q2): player fire is arc-gated like everyone else's. This is the same // predicate (FireControl.InArc) Combat.cs and TurretController.cs gate AI and turret fire with, read at 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 aeeee7efc..853bea91e 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -43,7 +43,10 @@ public abstract class Entity public Entity Parent; public List Children = new List(); - public ReactiveProperty Target = new ReactiveProperty((Entity)null); + // Mining Cut 3 (docs/mining-cut-refresh.md): the one target slot -- an entity, a chunk, or none. Read-only from + // outside: SetTarget is its one writer, for the player's handlers and the agents alike. + private readonly ReactiveProperty _target = new ReactiveProperty(TargetRef.None); + public IReadOnlyReactiveProperty Target => _target; // Cut 2 (docs/fire-control-cut.md): the stored aim-point slot. Written only by TrySelectTargetItem // below, and nulled whenever Target changes (subscribed in Activate). Do not read this field directly @@ -198,7 +201,7 @@ public abstract class Entity })); _subscriptions.Add(Zone.Entities.ObserveRemove().Subscribe(remove => { - if (Target.Value == remove.Value) Target.Value = null; + if (Target.Value.Entity == remove.Value) SetTarget(TargetRef.None); EntityInfoGathered.Remove(remove.Value); EntityHostility.Remove(remove.Value); VisibleEntities.Remove(remove.Value); @@ -226,13 +229,14 @@ public abstract class Entity _subscriptions.Add(_iffOverrides.ObserveRemove().Subscribe(remove => RefreshHostilityFromOverride(remove.Key))); _subscriptions.Add(VisibleEnemies.ObserveRemove().Subscribe(remove => { - if (Target.Value == remove.Value) Target.Value = null; + if (Target.Value.Entity == remove.Value) SetTarget(TargetRef.None); })); - _subscriptions.Add(Target.Subscribe(entity => entity?.TargetedBy.OnNext(this))); + // Only an entity is ever targeted by: a chunk has no one to tell. + _subscriptions.Add(Target.Subscribe(target => target.Entity?.TargetedBy.OnNext(this))); _subscriptions.Add(TargetedBy.Subscribe(enemy => { TargetedByCount.Value++; - enemy.Target.Where(t => t != this).Take(1).Subscribe(_ => TargetedByCount.Value--); + enemy.Target.Where(t => t.Entity != this).Take(1).Subscribe(_ => TargetedByCount.Value--); })); // @@ -297,8 +301,11 @@ public abstract class Entity // Deactivate nulling Target outright, because nulling on every dock would throw away a target // that is still alive and simply never changed while docked -- the common case, and not the one // that crashed. - if (Target.Value != null && !EntityInfoGathered.ContainsKey(Target.Value)) - Target.Value = null; + // Mining Cut 3: a chunk target is held to the same "still tracked" test through the chunk path -- kept + // while this entity can see it, dropped once it cannot (a broken chunk is dark, so it is dropped too). + if (Target.Value.Entity != null && !EntityInfoGathered.ContainsKey(Target.Value.Entity) || + Target.Value.Chunk is ChunkId heldChunk && !ChunkVisible(heldChunk)) + SetTarget(TargetRef.None); if(WeaponGroups.All(wg=>!wg.items.Any())) GenerateWeaponGroups(); @@ -316,7 +323,8 @@ public abstract class Entity return true; } - if (Target.Value == null || item.Entity != Target.Value || !FireControl.IsRevealed(this, item)) + // Mining Cut 3: entity-only. A chunk has no items, so an item never belongs to a chunk target. + if (Target.Value.Entity == null || item.Entity != Target.Value.Entity || !FireControl.IsRevealed(this, item)) return false; TargetItem.Value = item; @@ -327,10 +335,269 @@ public abstract class Entity // revealed to this entity, or no longer belongs to the current Target, even though nothing wrote // TargetItem.Value at the moment that became true. Re-checked on every call; never cached. public EquippedItem ResolvedTargetItem => - TargetItem.Value != null && Target.Value == TargetItem.Value.Entity && FireControl.IsRevealed(this, TargetItem.Value) + TargetItem.Value != null && Target.Value.Entity == TargetItem.Value.Entity && FireControl.IsRevealed(this, TargetItem.Value) ? TargetItem.Value : null; + // Mining Cut 3 (docs/mining-cut-refresh.md): the one writer of Target, called by the player's handlers and the + // agents alike. A chunk is taken only while this entity can see it, so a rock in darkness cannot be picked; an + // entity is written as given, as every handler wrote it before. Returns whether the write took effect. + public bool SetTarget(TargetRef target) + { + if (target.Chunk is ChunkId chunk && !ChunkVisible(chunk)) return false; + _target.Value = target; + return true; + } + + // Mining Cut 3 (Q14 A): this entity's info on a chunk, computed when asked and stored nowhere. It is the value + // the per-tick sensor rule (Sensor.Execute, through Sensor.Gain) would settle at for a target this bright, + // here, now. Each active sensor applies x <- saturate(x + G_i dt)(1 - k dt) every tick, so with n sensors the + // info settles where the gain balances the decay: sum(G_i) / (n k), capped at 1. A ping adds nothing that + // lasts, so only the passive gain enters. + public float ChunkInfo(ChunkId chunk) + { + var visibility = Zone.ChunkVisibility(chunk); + if (visibility <= 0f) return 0f; + var position = Zone.ChunkPose(chunk.Field, chunk.Index).xy; + var rate = 0f; + var sensors = 0; + foreach (var sensor in ActiveSensors()) + { + rate += sensor.PassiveRate(visibility, position); + sensors++; + } + return sensors == 0 ? 0f : saturate(rate / (sensors * ItemManager.GameplaySettings.TargetInfoDecay)); + } + + // Mining Cut 3: the detection threshold, read through the chunk path. A chunk is visible exactly as an entity + // enters VisibleEntities: its info exceeds TargetDetectionInfoThreshold. + 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), 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) + { + into.Clear(); + Zone.Targets.Within(MiningSearch(), _targetCandidates); + foreach (var candidate in _targetCandidates) + if (candidate.Target.Chunk is ChunkId chunk && ChunkVisible(chunk)) + into.Add(chunk); + into.Sort(ByIndexThenField); + } + + // The one search every chunk question asks the zone's targeting index: from here, as far as the longest range + // among this entity's active weapons that can mine (Q13 A; Weapon.CanMine, Q12 A), bounded by what its sensors + // could detect. + private TargetSearch MiningSearch() + { + var reach = 0f; + foreach (var weapon in _weapons) + if (weapon.CanMine && (weapon.Item == null || weapon.Item.Active.Value)) + reach = max(reach, weapon.Range); + return new TargetSearch(Position.xz, reach, reachPerVisibility: DetectionReachPerVisibility()); + } + + // Mining target-queries cut: which target a press of reticle, next, previous or nearest picks. Each is one + // best-first question to the index (no candidate list is built, sorted or kept), answered through SetTarget, the + // one writer. Candidates are visible ships at any range and visible chunks within mining reach. + + // The candidate nearest the look direction by planar angle; picking the held target again clears it. + public bool TargetUnderReticle() + { + var look = float2(LookDirection.x, LookDirection.z); + if (!PickTarget(new AngleKey(this, Position.xz, look), out var picked)) return false; + return SetTarget(Target.Value.Equals(picked) ? TargetRef.None : picked); + } + + // Enemies only, as it drives weapon lock; the nearest by distance. + public bool TargetNearestEnemy() + { + Entity nearest = null; + var nearestDistance = float.PositiveInfinity; + foreach (var enemy in VisibleEnemies) + { + if (enemy == this) continue; + var distance = length(enemy.Position - Position); + if (distance < nearestDistance) + { + nearest = enemy; + nearestDistance = distance; + } + } + return nearest != null && SetTarget(nearest); + } + + // The next candidate after the current target by (planar distance, TargetRef), wrapping to the nearest; with + // nothing targeted, the nearest. + public bool TargetNext() => CycleTarget(false); + + // The reverse: the next one nearer, wrapping to the farthest; with nothing targeted, the farthest. + public bool TargetPrevious() => CycleTarget(true); + + private bool CycleTarget(bool reverse) + { + var origin = Position.xz; + var current = Target.Value; + TargetRef picked = default; + var found = !current.IsNone && + PickTarget(new DistanceKey(this, origin, reverse, true, DistanceFrom(origin, current), current), out picked); + if (!found && !PickTarget(new DistanceKey(this, origin, reverse, false, 0f, default), out picked)) return false; + return SetTarget(picked); + } + + private float DistanceFrom(float2 origin, TargetRef target) => + length((target.Chunk is ChunkId chunk ? Zone.ChunkPose(chunk.Field, chunk.Index).xy : target.Entity.Position.xz) - origin); + + // The least-key eligible candidate: the best visible ship, and the index's best chunk within mining reach. Ships + // are not bounded by reach, so they are read from VisibleEntities (the ships this entity sees); the belt's size + // never enters. + private bool PickTarget(TKey query, out TargetRef picked) where TKey : ITargetKey + { + var best = default(TargetCandidate); + var bestKey = float.PositiveInfinity; + var found = false; + foreach (var entity in VisibleEntities) + Consider(query, new TargetCandidate(entity, entity.Position.xz), ref found, ref best, ref bestKey); + if (Zone.Targets.Best(MiningSearch(), query, out var chunk)) + Consider(query, chunk, ref found, ref best, ref bestKey); + picked = found ? best.Target : TargetRef.None; + return found; + } + + private static void Consider(TKey query, in TargetCandidate candidate, ref bool found, ref TargetCandidate best, ref float bestKey) + where TKey : ITargetKey + { + if (!query.Key(candidate, out var key)) return; + if (found && !(key < bestKey || key == bestKey && query.Precedes(candidate, best))) return; + best = candidate; + bestKey = key; + found = true; + } + + // What a press may pick: another ship, or a chunk this entity can see. Reach is the index's to apply. + private bool Eligible(in TargetCandidate candidate) => + candidate.Target.Chunk is ChunkId chunk ? ChunkVisible(chunk) : candidate.Target.Entity != this; + + // Entities come before chunks; entities by their place in the zone, chunks by field key then index. + private static int Order(TargetRef a, TargetRef b) + { + if (a.Chunk is ChunkId ca) + { + if (!(b.Chunk is ChunkId cb)) return 1; + var byField = string.CompareOrdinal(ca.Field.Value, cb.Field.Value); + return byField != 0 ? byField : ca.Index.CompareTo(cb.Index); + } + if (b.Chunk.HasValue) return -1; + var entities = a.Entity.Zone.Entities; + return entities.IndexOf(a.Entity).CompareTo(entities.IndexOf(b.Entity)); + } + + // The planar angle from the look direction to the candidate. A region's least angle is its bearing interval's + // distance from the look direction. + private readonly struct AngleKey : ITargetKey + { + private readonly Entity _self; + private readonly float2 _origin, _look; + + public AngleKey(Entity self, float2 origin, float2 look) + { + _self = self; + _origin = origin; + _look = look; + } + + public float Bound(in TargetRegion region) + { + var off = abs(atan2(_look.y, _look.x) - region.BearingCentre); + off = min(off, 2 * PI - off); + // Slack covers float rounding in the bearings; it only ever lowers the bound. + return max(0f, off - region.BearingHalfWidth - 1e-4f); + } + + public bool Key(in TargetCandidate candidate, out float key) + { + var to = candidate.Position - _origin; + key = abs(atan2(_look.x * to.y - _look.y * to.x, dot(_look, to))); + return _self.Eligible(candidate); + } + + public bool Precedes(in TargetCandidate a, in TargetCandidate b) => Order(a.Target, b.Target) < 0; + } + + // Planar distance from the ship, nearest first, or farthest first when reversed. With a pivot only candidates + // strictly after it by (distance, TargetRef) are eligible, so cycling is a total, stable order. + private readonly struct DistanceKey : ITargetKey + { + private readonly Entity _self; + private readonly float2 _origin; + private readonly bool _reverse, _hasPivot; + private readonly float _pivotDistance; + private readonly TargetRef _pivot; + + public DistanceKey(Entity self, float2 origin, bool reverse, bool hasPivot, float pivotDistance, TargetRef pivot) + { + _self = self; + _origin = origin; + _reverse = reverse; + _hasPivot = hasPivot; + _pivotDistance = pivotDistance; + _pivot = pivot; + } + + public float Bound(in TargetRegion region) + { + if (_hasPivot && (_reverse ? region.Nearest > _pivotDistance : region.Farthest < _pivotDistance)) + return float.PositiveInfinity; + return _reverse ? -region.Farthest : region.Nearest; + } + + public bool Key(in TargetCandidate candidate, out float key) + { + var distance = length(candidate.Position - _origin); + key = _reverse ? -distance : distance; + if (!_self.Eligible(candidate)) return false; + if (!_hasPivot) return true; + var order = Order(candidate.Target, _pivot); + return _reverse + ? distance < _pivotDistance || distance == _pivotDistance && order < 0 + : distance > _pivotDistance || distance == _pivotDistance && order > 0; + } + + public bool Precedes(in TargetCandidate a, in TargetCandidate b) => + _reverse ? Order(a.Target, b.Target) > 0 : Order(a.Target, b.Target) < 0; + } + + 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. + private IEnumerable ActiveSensors() => + Equipment.Where(item => item.Active.Value).SelectMany(item => item.Behaviors).OfType() + .Concat(_activeConsumables.SelectMany(effect => effect.Behaviors).OfType()); + // Cut 12.3 (docs/fire-control-cut.md): "armour absorbs first" -- the per-cell armour phase, split out of // what used to be Absorb's own first half (moved verbatim from DamageSchematic's per-cell body, Cut 3) so // Cut 12.3's fix batch (proportional multi-lane item absorption, below) can call the armour phase once per @@ -1078,7 +1345,12 @@ public abstract class Entity var hullData = ItemManager.GetData(Hull) as HullData; - TargetRange = Target.Value == null ? -1 : length(Position - Target.Value.Position); + // Mining Cut 3: a chunk target is lost the moment this entity can no longer see it -- the lost-track rule + // through the chunk path. One check of the one slot per tick, not a detection loop over chunks. + if (Target.Value.Chunk is ChunkId trackedChunk && !ChunkVisible(trackedChunk)) SetTarget(TargetRef.None); + TargetRange = Target.Value.Chunk is ChunkId rangedChunk + ? length(Position.xz - Zone.ChunkPose(rangedChunk.Field, rangedChunk.Index).xy) + : Target.Value.Entity == null ? -1 : length(Position - Target.Value.Entity.Position); var localSecurityLevel = Zone.GetSecurityLevel(Position.xz); if (CurrentSecurityLevel.Value != localSecurityLevel) CurrentSecurityLevel.Value = localSecurityLevel; diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 7a3586803..04e960d33 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -72,18 +72,26 @@ public static class FireControl // (Fire; operator ruling 2026-09-30, "the user is hoping that the arc gets the weapon close enough to still // splash some damage"). A weapon that only hits what it fires at still refuses. Fire reads InArc itself to // choose that flight, so the two share the one bearing test and differ only in this exemption. + // Mining Cut 3: a chunk target bears through its own position, on the plane the bearing test reads. public static bool ArcPermitsFire(Weapon weapon, Entity shooter) { var target = shooter.Target.Value; - if (target == null) return true; - return FuseOf(weapon.Item, out _) != null || InArc(weapon.Item, target.Position - shooter.Position); + if (target.IsNone) return true; + float3 toTarget; + if (target.Chunk is ChunkId chunk) + { + var at = shooter.Zone.ChunkPose(chunk.Field, chunk.Index).xy; + toTarget = float3(at.x, 0, at.y) - shooter.Position; + } + else toTarget = target.Entity.Position - shooter.Position; + return FuseOf(weapon.Item, out _) != null || InArc(weapon.Item, toTarget); } // Whether the shooter's next round from this weapon is refused: the weapons ask before they spend anything, so // a refused round costs no ammo, energy, sound, heat, wear or visibility (operator ruling 2026-09-30). The // answer is Solve's own -- there is no second copy of the arming test. public static bool Refuses(Weapon weapon, Entity shooter) => - Solve(weapon, weapon.Item, shooter, shooter.Target.Value).Outcome == FireOutcome.Refused; + Solve(weapon, weapon.Item, shooter, shooter.Target.Value.Entity).Outcome == FireOutcome.Refused; // The agents' trigger decision (Combat, TurretController), one weapon at a time (operator ruling 2026-09-30). // A weapon that fires at what it hits fires when the shot is worth taking. A fused weapon fires whenever its @@ -522,7 +530,8 @@ public static class FireControl public static int Fire(Weapon weapon, EquippedItem item, Entity source, float? damageOverride = null) { var zone = source.Zone; - var target = source.Target.Value; + // Mining Cut 3: a chunk target fires as an untargeted round until Cut 4 routes shots at chunks. + var target = source.Target.Value.Entity; var now = zone.Time; // R1's engage gate and the shooter-side probability, evaluated now and frozen: nothing at commit time diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index ffd369b01..67de2f0aa 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -29,7 +29,6 @@ public class PlanetSettings public ExponentialLerp AsteroidHitpoints; public ExponentialLerp AsteroidRespawnTime; public float GravityStrength; - public float MiningDifficulty = 500f; public float SecureAreaRadiusMultiplier = .45f; public ExponentialCurve OrbitPeriod; diff --git a/Assets/Scripts/ServerShared/Ship.cs b/Assets/Scripts/ServerShared/Ship.cs index 0190f4f02..d10f097ca 100644 --- a/Assets/Scripts/ServerShared/Ship.cs +++ b/Assets/Scripts/ServerShared/Ship.cs @@ -81,7 +81,7 @@ public class Ship : Entity public void EnterWormhole(float2 wormholePosition) { - Target.Value = null; + SetTarget(TargetRef.None); _wormholeAnimationProgress = 0; _enteringWormhole = true; _wormholeEntryPosition = Position.xz; 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..cba7ea628 --- /dev/null +++ b/Assets/Scripts/ServerShared/Targeting/BeltTargets.cs @@ -0,0 +1,300 @@ +/* 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) + { + 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 * Math.PI)); + // 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 * Math.PI) + 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); + 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 * Math.PI)), 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 = Math.PI; + } + else + { + var into = Turn(turn - From); + offAngle = into <= Width ? 0 : Math.Min(into - Width, 1 - into) * 2 * Math.PI; + // The sector point farthest in angle: the antipode if the sector holds it, else the farther end. + farAngle = Turn(turn + .5 - From) <= Width + ? Math.PI + : Math.Max(Separation(turn, From), Separation(turn, From + Width)) * 2 * Math.PI; + } + 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 * Math.PI; + var halfAngle = Width * Math.PI; + 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/Behaviors/MiningTool.cs.meta b/Assets/Scripts/ServerShared/Targeting/BeltTargets.cs.meta similarity index 83% rename from Assets/Scripts/ServerShared/Behaviors/MiningTool.cs.meta rename to Assets/Scripts/ServerShared/Targeting/BeltTargets.cs.meta index 9985f36a2..853bba9c2 100644 --- a/Assets/Scripts/ServerShared/Behaviors/MiningTool.cs.meta +++ b/Assets/Scripts/ServerShared/Targeting/BeltTargets.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: a7eebfaae57b2304580ef7e4a2740993 +guid: d33fc585810446f7b0a9319196c74633 MonoImporter: externalObjects: {} serializedVersion: 2 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 0af842695..d3973d92e 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -6,14 +6,11 @@ using System; using System.Collections; using System.Collections.Generic; using System.Linq; -using System.Threading; -using System.Threading.Tasks; using GameCult.Caching; using UniRx; using CultMath; using static CultMath.math; using float2 = CultMath.float2; -using Random = CultMath.Random; public class Zone { @@ -26,13 +23,22 @@ 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; private double _time; - private Random _random; public List Agents = new List(); + // Cut 2 (docs/mining-cut.md): chunk wear, keyed by chunk. Zone.Wear is the only writer; ChunkExists and + // ChunkRadius are the only readers besides the pack/unpack round trip below. + private Dictionary _wear = new Dictionary(); + // Cut 3 (docs/fire-control-cut.md, 0b table): Zone owns the pending-shot collection and the ShotId // namespace; FireControl owns every transition a shot goes through. ShotCommitted publishes once a shot's // outcome is decided (R4's commit horizon); ShotResolved republishes the same, unchanged outcome at @@ -58,12 +64,13 @@ public class Zone return false; } - private List BeltUpdates = new List(); - public float Time { 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; } @@ -83,7 +90,6 @@ public class Zone _itemManager = itemManager; Settings = settings; CombatSeed = galaxyZone?.Name.StableHash() ?? 1337u; - _random = new Random(CombatSeed); var cache = itemManager.ItemData; // Cut 5, 5.6 (docs/fire-control-cut.md, Soul finding 7; operator ruling Q3): death removes the ship, @@ -92,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))); @@ -104,10 +112,16 @@ public class Zone switch (planet) { case AsteroidBeltData belt: + // Mining Cut 3 (Q16 B): a belt from before field kinds existed gets its kind here, once, by + // 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])); @@ -118,6 +132,13 @@ public class Zone } } + // Cut 2 (docs/mining-cut.md): read after belts are built so a later ChunkExists/ChunkRadius call has + // Planets/AsteroidBelts populated. A pack from before key 6 existed has ChunkWear == null (nullable + // persistence rule); that reads as no wear rather than throwing. + if (pack.ChunkWear != null) + foreach (var wear in pack.ChunkWear) + _wear[new ChunkId(wear.Field, wear.Index)] = new ChunkWear { Damage = wear.Damage, BrokenUntil = wear.BrokenUntil }; + foreach (var entityPack in pack.Entities) { var entity = EntitySerializer.Unpack(_itemManager, this, entityPack); @@ -160,34 +181,26 @@ public class Zone Entities = Entities.Select(EntitySerializer.Pack).ToList(), Orbits = Orbits.Keys.Select(key => new CultRecordRef(key)).ToList(), Planets = Planets.Keys.Select(key => new CultRecordRef(key)).ToList(), - Time = _time + Time = _time, + // Cut 2 (docs/mining-cut.md): drop entries that carry no live information -- healed (no damage) and + // not currently broken -- so a save never accumulates wear rows for chunks nobody has touched since + // they last respawned. + ChunkWear = _wear + .Where(kv => !IsExpired(kv.Value)) + .Select(kv => new ChunkWearPack { Field = kv.Key.Field, Index = kv.Key.Index, Damage = kv.Value.Damage, BrokenUntil = kv.Value.BrokenUntil }) + .ToList() }; } + private bool IsExpired(ChunkWear wear) => wear.Damage <= 0f && (!wear.BrokenUntil.HasValue || wear.BrokenUntil.Value <= _time); + public void AddOrbit(OrbitData orbit) { - SettleBelts(); Orbits.Add(_itemManager.ItemData.RefOf(orbit).Key, new Orbit(Settings, orbit)); } - // The asteroid belts' tasks read the clock, the orbits and each belt's damage and respawn state. Everything that - // writes any of those settles the running tasks first. The list is cleared even when a task failed, so a failure - // surfaces once, here, instead of on every later tick. - private void SettleBelts() - { - try - { - Task.WaitAll(BeltUpdates.ToArray()); - } - finally - { - BeltUpdates.Clear(); - } - } - public void Update(float deltaTime) { - SettleBelts(); _time += deltaTime; _updatedOrbits.Clear(); foreach (var orbit in Orbits) @@ -197,13 +210,6 @@ public class Zone orbit.Value.Velocity = (orbit.Value.Position - orbit.Value.PreviousPosition) / deltaTime; } - foreach (var belt in AsteroidBelts) - { - Array.Copy(belt.Value.NewTransforms, belt.Value.Transforms, belt.Value.Transforms.Length); - belt.Value.OrbitPosition = belt.Value.NewOrbitPosition; - BeltUpdates.Add(Task.Run(() => UpdateAsteroidTransforms(belt.Key))); - } - foreach(var agent in Agents) agent.Update(deltaTime); @@ -260,55 +266,89 @@ public class Zone return float2.zero; } - public int NearestAsteroid(CultRecordKey planetDataID, float2 position) + // Cut 2 (docs/mining-cut.md): the index is in range for its field and the chunk is not broken at zone time. + // Respawn is a comparison against zone time, never a per-tick loop or a write on read. + public bool ChunkExists(ChunkId chunk) { - var beltData = Planets[planetDataID] as AsteroidBeltData; - - var asteroidPositions = AsteroidBelts[planetDataID].Transforms; - - int nearest = 0; - float nearestDistance = Single.MaxValue; - for (int i = 0; i < beltData.Asteroids.Length; i++) - { - var dist = lengthsq(asteroidPositions[i].xz - position); - if (AsteroidExists(planetDataID, i) && dist < nearestDistance) - { - nearest = i; - nearestDistance = dist; - } - } - - return nearest; + if (!(Planets.TryGetValue(chunk.Field, out var body) && body is AsteroidBeltData beltData)) + return false; + if (chunk.Index < 0 || chunk.Index >= beltData.Asteroids.Length) + return false; + return !(_wear.TryGetValue(chunk, out var wear) && wear.BrokenUntil.HasValue && wear.BrokenUntil.Value > _time); } - public bool AsteroidExists(CultRecordKey planetDataID, int asteroid) => ((AsteroidBeltData) Planets[planetDataID]).Asteroids.Length > asteroid && asteroid >= 0; - - private void UpdateAsteroidTransforms(CultRecordKey planetDataID) + // Cut 2: the size rule of AsteroidBelt.Size (Cut 1), now reading wear from its one owner (Zone) instead of + // per-belt dictionaries. A broken chunk (BrokenUntil in the future) has no size; an unbroken, undamaged + // chunk reads its authored size; a damaged one shrinks toward it. + public float ChunkRadius(ChunkId chunk) { - var beltData = Planets[planetDataID] as AsteroidBeltData; + var belt = AsteroidBelts[chunk.Field]; + if (!_wear.TryGetValue(chunk, out var wear)) + return belt.UndamagedSize(chunk.Index, Settings); + if (wear.BrokenUntil.HasValue && wear.BrokenUntil.Value > _time) + return 0f; + if (wear.Damage <= 0f) + return belt.UndamagedSize(chunk.Index, Settings); + return belt.DamagedSize(chunk.Index, wear.Damage, Settings); + } - var belt = AsteroidBelts[planetDataID]; + // Cut 2: the only writer of chunk wear. Returns whether this hit broke the chunk. The break threshold is + // strictly greater-than, matching the deleted per-tick miner's own comparison against accumulated damage: + // damage exactly equal to hitpoints does not yet break the chunk, only damage that exceeds them does. + // A chunk that is currently broken (zone time < BrokenUntil) absorbs nothing: this hit did not break it + // (it was already broken), and its wear stays zeroed until it respawns and starts fresh. + public bool Wear(ChunkId chunk, float damage) + { + _wear.TryGetValue(chunk, out var wear); + if (wear.BrokenUntil.HasValue && wear.BrokenUntil.Value > _time) + return false; - var orbitData = Orbits[beltData.Orbit.Key].Data; - belt.NewOrbitPosition = GetOrbitPosition(orbitData.Parent.Key); - for (var i = 0; i < beltData.Asteroids.Length; i++) + var beltData = (AsteroidBeltData) Planets[chunk.Field]; + var size = beltData.Asteroids[chunk.Index].Size; + var hitpoints = Settings.AsteroidHitpoints.Evaluate(size); + + var newDamage = wear.Damage + damage; + + if (newDamage > hitpoints) { - float size; - if(belt.RespawnTimers.ContainsKey(i)) size = 0; - else if (belt.Damage.ContainsKey(i)) - { - var asteroidHitpoints = Settings.AsteroidHitpoints.Evaluate(beltData.Asteroids[i].Size); - var damage = (asteroidHitpoints - belt.Damage[i]) / asteroidHitpoints; - size = Settings.AsteroidSize.Evaluate(damage * beltData.Asteroids[i].Size); - } - else size = Settings.AsteroidSize.Evaluate(beltData.Asteroids[i].Size); - - var rot = (float) (_time * beltData.Asteroids[i].RotationSpeed % (PI * 2)); - var pos = OrbitData.Evaluate((float) frac(_time / Settings.OrbitPeriod.Evaluate(beltData.Asteroids[i].Distance) + - beltData.Asteroids[i].Phase)) * beltData.Asteroids[i].Distance + belt.NewOrbitPosition; - //belt.NewPositions[i] = float3(pos.x, GetHeight(pos) + Settings.AsteroidVerticalOffset, pos.y); - belt.NewTransforms[i] = float4(pos.x, pos.y, rot, size); + var respawnTime = Settings.AsteroidRespawnTime.Evaluate(size); + _wear[chunk] = new ChunkWear { Damage = 0f, BrokenUntil = _time + respawnTime }; + return true; } + + _wear[chunk] = new ChunkWear { Damage = newDamage, BrokenUntil = null }; + return false; + } + + // Cut 1 (docs/mining-cut.md): a chunk's pose is a pure function of zone time, computed when asked. No stored + // pose survives a tick; the sim and the renderer both read it fresh through here. + public float4 ChunkPose(CultRecordKey belt, int index) + { + var beltData = Planets[belt] as AsteroidBeltData; + var parentPosition = GetOrbitPosition(Orbits[beltData.Orbit.Key].Data.Parent.Key); + var size = ChunkRadius(new ChunkId(belt, index)); + return AsteroidBelts[belt].Pose(index, _time, parentPosition, Settings, size); + } + + // Mining Cut 3 (docs/mining-cut-refresh.md): how bright a chunk is -- Reflector's rule (cross-section × the light + // on it), with the field kind's reflectivity per schematic cell times the chunk's area in cells. Computed when + // asked; nothing about it is stored. A worn chunk shrinks and dims; a broken one, or one of a kindless belt, is dark. + public float ChunkVisibility(ChunkId chunk) + { + if (!ChunkExists(chunk)) return 0f; + var kind = _itemManager.ItemData.Get(((AsteroidBeltData) Planets[chunk.Field]).Kind); + if (kind == null) return 0f; + var cells = ChunkRadius(chunk) / _itemManager.GameplaySettings.SchematicCellSize; + return kind.CrossSection * PI * cells * cells * GetLight(ChunkPose(chunk.Field, chunk.Index).xy); + } + + public void EvaluateBelt(CultRecordKey belt, Span into) + { + var beltData = Planets[belt] as AsteroidBeltData; + var parentPosition = GetOrbitPosition(Orbits[beltData.Orbit.Key].Data.Parent.Key); + var beltInstance = AsteroidBelts[belt]; + for (var i = 0; i < beltData.Asteroids.Length; i++) + into[i] = beltInstance.Pose(i, _time, parentPosition, Settings, ChunkRadius(new ChunkId(belt, i))); } public OrbitData CreateOrbit(CultRecordKey parent, float2 position) @@ -321,7 +361,6 @@ public class Zone var currentPhase = frac(_time / period); var storedPhase = (float) frac(phase - currentPhase); - SettleBelts(); var orbit = new OrbitData { Distance = distance, @@ -332,47 +371,6 @@ public class Zone return orbit; } - public void MineAsteroid(Entity miner, CultRecordKey asteroidBelt, int asteroid, float damage, float efficiency, float penetration) - { - SettleBelts(); - var beltData = Planets[asteroidBelt] as AsteroidBeltData; - var belt = AsteroidBelts[asteroidBelt]; - //var asteroidTransform = belt.Transforms[asteroid]; - - var size = beltData.Asteroids[asteroid].Size; - var asteroidHitpoints = Settings.AsteroidHitpoints.Evaluate(size); - - if (!belt.Damage.ContainsKey(asteroid)) - belt.Damage[asteroid] = 0; - belt.Damage[asteroid] = belt.Damage[asteroid] + damage; - - if (!belt.MiningAccumulator.ContainsKey((miner, asteroid))) - belt.MiningAccumulator[(miner, asteroid)] = 0; - belt.MiningAccumulator[(miner, asteroid)] = belt.MiningAccumulator[(miner, asteroid)] + damage; - - if (belt.Damage[asteroid] > asteroidHitpoints) - { - belt.RespawnTimers[asteroid] = Settings.AsteroidRespawnTime.Evaluate(size); - belt.Damage.Remove(asteroid); - belt.MiningAccumulator.Remove((miner, asteroid)); - return; - } - - var resourceCount = beltData.Resources.Sum(x => x.Value); - var resource = beltData.Resources.MaxBy(x => pow(x.Value, 1f / penetration) * _random.NextFloat()); - if (efficiency * _random.NextFloat() * belt.MiningAccumulator[(miner, asteroid)] * resourceCount / Settings.MiningDifficulty > 1) - { - belt.MiningAccumulator.Remove((miner, asteroid)); - // var newSimpleCommodity = new SimpleCommodity - // { - // Data = resource.Key, - // Quantity = 1 - // }; - // TODO: Drop item onto the Grid - //miner.AddCargo(newSimpleCommodity); - } - } - public SecurityLevel GetSecurityLevel(float2 pos) { if (GalaxyZone.Owner==null) return SecurityLevel.Open; @@ -423,23 +421,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); @@ -541,22 +536,97 @@ public class Sun : GasGiant public class AsteroidBelt { public AsteroidBeltData Data; - public float4[] Transforms; // x, y, rotation, scale - public float4[] NewTransforms; // x, y, rotation, scale public float Radius { get; } - public float2 OrbitPosition; - public float2 NewOrbitPosition; - public Dictionary RespawnTimers = new Dictionary(); - public Dictionary Damage = new Dictionary(); - public Dictionary<(Entity, int), float> MiningAccumulator = new Dictionary<(Entity, int), float>(); public AsteroidBelt(AsteroidBeltData data) { Data = data; - Transforms = new float4[data.Asteroids.Length]; - NewTransforms = new float4[data.Asteroids.Length]; Radius = data.Asteroids.Max(a => a.Distance); } + + // Cut 1 (docs/mining-cut.md): the formula of Zone.cs:269-271 verbatim, `time` in double as `_time` was used. + // The only place a chunk pose is computed; no per-tick copy survives it. Cut 2: size is no longer read from + // per-belt storage -- Zone owns wear now, so the caller (Zone.ChunkPose/EvaluateBelt) computes it through + // Zone.ChunkRadius and passes it in. + public float4 Pose(int index, double time, float2 parentPosition, PlanetSettings settings, float size) + { + var asteroid = Data.Asteroids[index]; + var rot = (float) (time * asteroid.RotationSpeed % (PI * 2)); + var pos = OrbitData.Evaluate((float) frac(time / settings.OrbitPeriod.Evaluate(asteroid.Distance) + + asteroid.Phase)) * asteroid.Distance + parentPosition; + return float4(pos.x, pos.y, rot, size); + } + + // Cut 1's size rule (Zone.cs:259-267), split in Cut 2 into its undamaged and damaged halves now that wear + // lives on Zone instead of here. Neither reads wear directly; the caller (Zone.ChunkRadius) decides which + // applies and supplies the damage. + public float UndamagedSize(int index, PlanetSettings settings) => settings.AsteroidSize.Evaluate(Data.Asteroids[index].Size); + + public float DamagedSize(int index, float damage, PlanetSettings settings) + { + var asteroidHitpoints = settings.AsteroidHitpoints.Evaluate(Data.Asteroids[index].Size); + var remaining = (asteroidHitpoints - damage) / asteroidHitpoints; + return settings.AsteroidSize.Evaluate(remaining * Data.Asteroids[index].Size); + } +} + +// Cut 2 (docs/mining-cut.md): a chunk is (field, index). "Field" is deliberate, not "belt" -- the operator's +// ruling generalizes chunks to other kinds of debris field later; only AsteroidBelt/AsteroidBeltData, the one +// live field kind, keep their existing names. +public readonly struct ChunkId : IEquatable +{ + public readonly CultRecordKey Field; + public readonly int Index; + + public ChunkId(CultRecordKey field, int index) + { + Field = field; + Index = index; + } + + public bool Equals(ChunkId other) => Field.Equals(other.Field) && Index == other.Index; + public override bool Equals(object obj) => obj is ChunkId other && Equals(other); + public override int GetHashCode() => (Field.GetHashCode() * 397) ^ Index; +} + +// Mining Cut 3 (docs/mining-cut-refresh.md, Q2 A): what an entity targets -- another entity, a chunk, or nothing. +// One slot holds either kind (Entity.Target, written only by Entity.SetTarget). A chunk never equals an entity. +// There is deliberately no == operator: a comparison against null or an entity must say which side it means. +public readonly struct TargetRef : IEquatable +{ + public readonly Entity Entity; + public readonly ChunkId? Chunk; + + public TargetRef(Entity entity) + { + Entity = entity; + Chunk = null; + } + + public TargetRef(ChunkId chunk) + { + Entity = null; + Chunk = chunk; + } + + public static TargetRef None => default; + public bool IsNone => Entity == null && !Chunk.HasValue; + + public static implicit operator TargetRef(Entity entity) => new TargetRef(entity); + public static implicit operator TargetRef(ChunkId chunk) => new TargetRef(chunk); + + public bool Equals(TargetRef other) => ReferenceEquals(Entity, other.Entity) && Nullable.Equals(Chunk, other.Chunk); + public override bool Equals(object obj) => obj is TargetRef other && Equals(other); + public override int GetHashCode() => Chunk.HasValue ? Chunk.Value.GetHashCode() : Entity?.GetHashCode() ?? 0; +} + +// Cut 2: wear on one chunk. BrokenUntil is the absolute zone time the chunk respawns at; null means the chunk +// carries damage (or none) but is not currently broken. Respawn is read lazily by comparing to zone time -- +// nothing decrements or clears this on a timer. +public struct ChunkWear +{ + public float Damage; + public double? BrokenUntil; } public class Orbit diff --git a/Assets/Scripts/ServerShared/ZoneData.cs b/Assets/Scripts/ServerShared/ZoneData.cs index 90158fdb4..2216355d6 100644 --- a/Assets/Scripts/ServerShared/ZoneData.cs +++ b/Assets/Scripts/ServerShared/ZoneData.cs @@ -5,6 +5,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Linq; using GameCult.Caching; using MessagePack; using Newtonsoft.Json; @@ -35,7 +36,32 @@ public class ZonePack [JsonProperty("time"), Key(5)] public double Time; + // Cut 2 (docs/mining-cut.md): chunk wear (damage taken, broken-until time), keyed by chunk. Nullable, not + // defaulted to an empty list: an older record has no key 6 at all, and MessagePack fills a missing trailing + // key with nil, which a reference-typed field reads back as null. ARunWithoutKeySixLoadsWhole pins that a + // record from before this key existed loads with no wear, rather than throwing or silently reading a + // default instance. + [JsonProperty("chunkWear"), Key(6)] + public List ChunkWear; +} +// Cut 2 (docs/mining-cut.md): one packed wear entry for one chunk. "Field" names the body that owns the chunk +// (an asteroid belt today; the operator's ruling generalizes chunks to other kinds of debris field later), not +// "belt" -- the chunk surface itself must not bake in the one live field kind. +[MessagePackObject, JsonObject(MemberSerialization.OptIn)] +public class ChunkWearPack +{ + [JsonProperty("field"), Key(0)] + public CultRecordKey Field; + + [JsonProperty("index"), Key(1)] + public int Index; + + [JsonProperty("damage"), Key(2)] + public float Damage; + + [JsonProperty("brokenUntil"), Key(3)] + public double? BrokenUntil; } [JsonObject(MemberSerialization.OptIn)] @@ -77,6 +103,64 @@ public class AsteroidBeltData : BodyData { [JsonProperty("asteroids"), Key(9)] public Asteroid[] Asteroids; + + // Mining Cut 3 (docs/mining-cut-refresh.md, Q16 B): the belt's field kind, chosen once and saved. Unset on a + // belt from before this key existed (MessagePack leaves a missing trailing key at its default); Zone assigns + // it on first load through FieldKinds.Ensure, the same function generation uses. + [JsonProperty("kind"), Key(10)] + public CultRecordRef Kind; +} + +// Mining Cut 3 (docs/mining-cut-refresh.md, Q11): a kind of debris field, an authored catalog record. A chunk's +// reflectivity comes from its field's kind; Cut 5 adds the yield table. +[CultDocument("aetheria.fieldkinddata", "1"), MessagePackObject, JsonObject(MemberSerialization.OptIn)] +public class FieldKindData +{ + [CultName, JsonProperty("name"), Key(0)] + public string Name = ""; + + // Reflectivity per schematic cell of chunk area: a chunk reflects CrossSection × its area in cells × the light + // on it (Zone.ChunkVisibility), the rule Reflector applies to a hull. + [JsonProperty("crossSection"), Key(1)] + public float CrossSection; + + // How often generation picks this kind, relative to the others (FieldKinds.Assign). Zero never picks it. + [JsonProperty("generationWeight"), Key(2)] + public float GenerationWeight = 1; +} + +// Mining Cut 3 (Q16 B): the one owner of which kind a belt is. +public static class FieldKinds +{ + // A weighted pick among the catalog's kinds, by a stable hash of the belt's key: the same belt and the same + // catalog always give the same kind. Kinds are ordered by key so the pick does not depend on store order. + // Unset when the catalog has no kind with positive weight. + public static CultRecordRef Assign(CultRecordKey field, CultCache catalog) + { + var kinds = catalog.GetAll() + .Where(kind => kind.GenerationWeight > 0f) + .Select(kind => (Key: catalog.RefOf(kind).Key, Weight: kind.GenerationWeight)) + .OrderBy(kind => kind.Key.Value, StringComparer.Ordinal) + .ToArray(); + if (kinds.Length == 0) return default; + + var pick = field.Value.StableHash() / 4294967296.0 * kinds.Sum(kind => (double) kind.Weight); + foreach (var kind in kinds) + { + if (pick < kind.Weight) return new CultRecordRef(kind.Key); + pick -= kind.Weight; + } + return new CultRecordRef(kinds[kinds.Length - 1].Key); + } + + // Gives a belt with no kind its kind and saves it; a belt that has one keeps it, whatever the catalog now says. + // Generation (ZoneGenerator) and first load (Zone) both call this, so there is one assignment rule. + public static void Ensure(CultRecordKey field, AsteroidBeltData belt, CultCache cache) + { + if (belt.Kind.IsSet()) return; + belt.Kind = Assign(field, cache); + if (belt.Kind.IsSet()) cache.Upsert(belt); + } } [CultDocument("aetheria.gasgiantdata", "1"), MessagePackObject, JsonObject(MemberSerialization.OptIn)] diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index ff25223fe..2ff4fe005 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -200,7 +200,10 @@ public static class ZoneGenerator } // A body is named for the first eight characters of the key the cache mints for it - planetData.Name = cache.Upsert(planetData).Key.Value.Substring(0, 8); + var bodyKey = cache.Upsert(planetData).Key; + planetData.Name = bodyKey.Value.Substring(0, 8); + // Mining Cut 3 (Q16 B): a belt's field kind is chosen here, at generation, and saved with it. + if (planetData is AsteroidBeltData generatedBelt) FieldKinds.Ensure(bodyKey, generatedBelt, cache); return cache.Upsert(planetData); }).ToList(); diff --git a/Assets/Scripts/Zone Display/ZoneRenderer.cs b/Assets/Scripts/Zone Display/ZoneRenderer.cs index b3fec3ac8..b6021027a 100644 --- a/Assets/Scripts/Zone Display/ZoneRenderer.cs +++ b/Assets/Scripts/Zone Display/ZoneRenderer.cs @@ -72,6 +72,9 @@ public class ZoneRenderer : MonoBehaviour private Dictionary _beltObjects = new Dictionary(); private Dictionary _beltMeshes = new Dictionary(); private Dictionary _beltMatrices = new Dictionary(); + // Cut 1 (docs/mining-cut.md): the renderer's own buffer, filled from Zone.EvaluateBelt each frame. No pose + // is stored on AsteroidBelt any more. + private Dictionary _beltTransforms = new Dictionary(); private float _viewDistance; //private float _maxDepth; private float _minimapDistance; @@ -282,6 +285,7 @@ public class ZoneRenderer : MonoBehaviour _beltObjects.Clear(); _beltMeshes.Clear(); _beltMatrices.Clear(); + _beltTransforms.Clear(); _tourPlanets.Clear(); } } @@ -346,6 +350,7 @@ public class ZoneRenderer : MonoBehaviour meshes.RemoveAt(Random.Range(0, meshes.Count)); _beltMeshes[key] = meshes.ToArray(); _beltMatrices[key] = new Matrix4x4[meshes.Count][]; + _beltTransforms[key] = new float4[beltData.Asteroids.Length]; var count = beltData.Asteroids.Length / meshes.Count; var remainder = beltData.Asteroids.Length - count * meshes.Count; for (int i = 0; i < meshes.Count; i++) @@ -449,25 +454,30 @@ public class ZoneRenderer : MonoBehaviour foreach (var (key, belt) in Zone.AsteroidBelts) { - var height = Zone.GetHeight(belt.OrbitPosition); + // Cut 1 (docs/mining-cut.md): belts have no stored pose any more. The renderer fills its own buffer + // from Zone.EvaluateBelt, at the current zone time, every frame. + var orbitPosition = Zone.GetOrbitPosition(Zone.Orbits[belt.Data.Orbit.Key].Data.Parent.Key); + var height = Zone.GetHeight(orbitPosition); + var transforms = _beltTransforms[key]; + Zone.EvaluateBelt(key, transforms); if(isVisible(new Bounds( - new Vector3(belt.OrbitPosition.x,height,belt.OrbitPosition.y), + new Vector3(orbitPosition.x,height,orbitPosition.y), new Vector3(belt.Radius * 2,100,belt.Radius * 2)))) { var meshes = _beltMeshes[key]; var matrices = _beltMatrices[key]; - var count = belt.Transforms.Length / meshes.Length; + var count = transforms.Length / meshes.Length; for (int i = 0; i < meshes.Length; i++) { for (int t = 0; t < matrices[i].Length; t++) { var tx = t + i * count; - var transform = belt.Transforms[tx]; + var transform = transforms[tx]; matrices[i][t] = Matrix4x4.TRS(new Vector3(transform.x,0,transform.y), Quaternion.Euler( cos(transform.z + (float)i / meshes.Length) * 100, sin(transform.z + (float)i / meshes.Length) * 100, - (float)tx / belt.Transforms.Length * 360), + (float)tx / transforms.Length * 360), Vector3.one * transform.w); } @@ -476,7 +486,7 @@ public class ZoneRenderer : MonoBehaviour } if(_showAsteroidUI) - _beltObjects[key].Update(belt.Transforms, height); + _beltObjects[key].Update(transforms, height); } foreach (var planet in Planets) @@ -662,7 +672,7 @@ public class AsteroidBeltUI public void Update(float4[] transforms, float height) { - var parentPosition = _belt.OrbitPosition; + var parentPosition = _zone.GetOrbitPosition(_orbitParent); for (var i = 0; i < transforms.Length; i++) { var transform = transforms[i]; diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index 20377e0db..d10c6d732 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:70a7b0a9f5a59bf64a0bff80f5d266083910e88cc141745b5082424e3ae30b79 -size 12701773 +oid sha256:78fe2afebd5caf98f7e01cf3c1cb3c7b11aed7d8971907f645c4929925e68f65 +size 12703072 diff --git a/docs/mining-cut-refresh.md b/docs/mining-cut-refresh.md new file mode 100644 index 000000000..54d3d9d81 --- /dev/null +++ b/docs/mining-cut-refresh.md @@ -0,0 +1,940 @@ +# Mining: refreshed cut map (v2, operator rulings of 2026-09-30) + +v1 dated 2026-09-29; this is v2, 2026-09-30. Imagination output for Self. It re-anchors the Codex mining lane to +the Body as it will stand after `codex/fire-control-12` merges to `master`, and it incorporates the operator's +rulings of 2026-09-30: + +| Question | Ruling | +|---|---| +| Q4 | A | +| Q5 | A | +| Q6a | A | +| Q6b | A | +| Q7 | A′ | +| Q8 | **B** | +| Q9 | **C** | +| Q10 | A | +| Q11 | authored catalog records that yield simple commodities only | +| Q12 | A | +| Q13 | A, plus the detection correction | + +The Codex map (`docs/mining-cut.md`) is raw material, not authority. Where the two disagree, this file is the +proposal. + +**What changed from v1:** +- **Chunk detection** now goes through the existing reflectance rule. It is no longer unconditional, and it is not + deferred to EW (Cut 3). +- **Cut 6 is deleted.** AI mining is deferred, and the same-path proof is a test agent in Cut 5. +- **Cut 7 authors Corrosive and Ionizing weapons.** +- **The field-kind record arrives in Cut 3** (for cross-section), not Cut 5. +- **Two new forks:** Q14 (how chunk sensor info integrates) and Q16 (how a belt gets its kind). +- **The CultMath-root claim** is settled in the shared precondition. + +The moddable-ships half of v1 lives in `docs/moddable-ships-cut.md` on `codex/moddable-ships`. Section 0's ship +rows are kept for the combined test count. + +## Rulings added 2026-09-30 (Self, after v2; operator confirmed Q14 A and Q16 B the same day) + +- **Q14 A.** A ship's knowledge of a rock is the value the per-tick sensor rule would settle at, computed on + demand through the shared `Sensor.Gain`. It is stateless and costs nothing per tick. +- **Q16 B.** A belt's field kind is chosen at generation and saved. A belt from an older run with no kind gets + one assigned once on first load, by the same function, and that assignment is saved. + +## How this was established + +- **Branch tips read, 2026-09-29, after `git fetch`.** + - `origin/master` `9b85211f` + - `origin/codex/fire-control-12` `b66ba524` + - `origin/codex/mining` `283ce7dc` + - `origin/codex/moddable-ships` `375d6bd4` + - Nothing in `F:\Projects\Aetheria`'s working tree was read or switched. Every read is `git show` or `git grep` + against a SHA. +- **The post-fire-control Body is a synthetic merge.** `git merge-tree --write-tree origin/master + origin/codex/fire-control-12` is clean, tree `91576243`. I wrapped it in an unreferenced local commit, + `8a2db490` (no ref, no push to origin), so it can be built. The fire-control checklist (`docs/merge-to-master-checklist.md:7,107` + on `b66ba524`) predicts the same clean merge. +- **Anchors.** Each `file:line` names a real SHA, never the synthetic one: + - `b66ba524` for files only fire control touched; + - `283ce7dc` for files the mining branch touched; + - `375d6bd4` for files the ships branch touched. + + The three file sets are disjoint. I checked with `git diff --name-only` over each branch's own range, and none + of the three pairs shares a file. So each anchored line is byte-identical in the merged tree. +- **Tests ran on Yggdrasil, against synthetic merges** (section 0). CultLib `45c2f40` and CultMath `6d5e209` are + the pins in `Directory.Build.props`. The two LFS files the tests read (`GameData/Aetheria.cc`, + `Assets/Resources/Settings.asset`) were staged beside the job, because the verify mirror carries no LFS. +- **Catalog facts** come from a probe that opens the merged tree's `GameData/Aetheria.cc` through + `AetheriaStores.Open` (section 0). + +## 0. Probe results + +**Test suite on the synthetic merges**, Yggdrasil, `dotnet test tests/Aetheria.Shared.Tests`, CultLib `45c2f40`, +CultMath `6d5e209`, the `dotnet` image: + +| Tree | Commit (local, unreferenced) | Result | +|---|---|---| +| master + fire-control-12 + mining | `396d19e5` | **379 passed, 0 failed** | +| master + fire-control-12 + ships | `0036204f` | **364 passed, 0 failed** | +| master + fire-control-12 + mining + ships | `474c35c3` | **381 passed, 0 failed** | + +Fire control closed 12.4 at 362 tests. 379 = 362 + 17 mining tests, 364 = 362 + 2 ship tests, and 381 is both +together. Nothing on either branch breaks a fire-control test, and nothing in fire control breaks a branch test. +This proves the headless simulation only. No Unity compile ran. + +**Catalog probe** (`scratchpad/probe/Program.cs`: it opens `GameData/Aetheria.cc` through `AetheriaStores.Open` +in the merged tree; the catalog oid `2aa79c0a` is the same in all three merges and at `b66ba524`): + +- 124 items: 32 `GearData`, 18 `WeaponItemData`, 13 `SimpleCommodityData`, 5 `HullData`, 51 compound + commodities, 4 cargo bays, 1 docking bay. +- **Weapon damage types:** Kinetic 9, Electric 3, Optical 3, Thermal 3. There is none of Corrosive or Ionizing, + though the `DamageType` enum has all six. The mining map said Kinetic 10. +- **Penetration > 0:** only Autocannon, LRMM72 and SRMM72, each at 0.25. +- **DamageSpread > 0:** GT 3K 3.25, plight 3.3, pswarm 1.5, scorched void policy 1.5, and SRMM72 0.5. The map + listed four and missed SRMM72. +- **No weapon has `BlastRadius` or `Fuse`.** Blasts are dormant in shipped content until F12-2 authors them. +- **Simple commodities:** the 13 the map named. Every one is priced 0 **except Ammo, at 1000**. The map's + "every live simple commodity has Price 0" is off by one. +- **No live item carries a scanner or mining behaviour**, on fire control or on mining merged. +- **Pre-existing, on `b66ba524` as well as on the merges:** four `GearData` records (Refinery, Deep Ore + Extractor, Assembly Line, Shipyard) hold a **null element** in `Behaviors` (index 0 of 1). It does not come from + mining's union-26 retirement: the same four are null on fire control alone. This matters to variants C4 (A.6). +- **Shipped hull mount ids are not unique:** + + | Hull | Hardpoints | Distinct `Transform`s | Empty `Transform`s | + |---|---|---|---| + | Djinni | 22 | 20 | 3 | + | Longinus | 13 | 12 | 2 | + | LonginusX | 10 | 9 | 2 | + | Turret | 7 | 3 | 5 | + | Zenith | 5 | 1 | 5 | + + All five hulls use `Prefab`. This matters to ship migration (`docs/moddable-ships-cut.md` S5). + +## Shared precondition for both campaigns + +Both campaigns wait on one event: `codex/fire-control-12` merging into `master` with `--no-ff`, which Self does +after the operator signs off (`docs/merge-to-master-checklist.md:105-113` on `b66ba524`). Until then, neither +campaign has a base to merge onto except the fire-control branch itself. After it: + +``` +git switch codex/mining && git merge --no-ff master # in a worktree, never the operator's tree +git switch codex/moddable-ships && git merge --no-ff master +``` + +**Merge, do not rebase.** Both maps and their Soul receipts cite landed SHAs: mining cites 11 of its 13 commits, +and the ships doc cites its commits' results. A rebase rewrites every one of those SHAs, which strands the maps' +evidence. The merge has no conflicts (below), so it costs one merge commit per branch. + +### Building fire-control-12 headless needs two CultLib checkouts + +Soul (ships branch) reported that fire-control-12 does not compile against its pinned CultLib `45c2f40`, with +`erf` and `erfinv` missing. v1's merges ran green on `45c2f40`. **Both claims are true. They describe two +different build invocations.** + +- `Directory.Build.props` @`b66ba524` pins two revisions of the same repository: `CultLibRevision` `45c2f40` + (CultCache) and `CultMathRevision` `6d5e209` (CultMath, tag `cultmath-unity-v0.2.4`). +- `CultMathRoot` defaults to `CultLibRoot` when it is not passed. +- `erf` and `erfinv` exist in CultMath at `6d5e209` (`packages/cultmath/src/CultMath/math.cs:528`). They do not + exist at `45c2f40`: `git grep` finds neither there. +- `FireControl.cs` calls them at `:1323` and `:1348` @`b66ba524` (Cut 12.0 added them to CultMath). + +Probed on Yggdrasil at `b66ba524`, `dotnet build Aetheria.Shared`: + +| Invocation | Result | +|---|---| +| A. `-p:CultLibRoot=/CultLib` only (at `45c2f40`) | **fails**: `FireControl.cs(1323,54) CS0103 'erf'`, `(1348,53) 'erfinv'` | +| B. as A, plus `-p:SkipCultLibRevisionCheck=true` | the same failure | +| C. `-p:CultLibRoot=/CultLib -p:CultMathRoot=/CultMath` (at `6d5e209`) | **builds** (v1's 379/381 runs used this) | + +**The rule for every Hands and Soul job:** pass both roots, each checked out clean at its own pin. Soul's run was +invocation A: a missing CultMath root, not a broken branch. + +**A second defect this surfaced (not a mining cut).** Case A should never have reached the compiler. The +`VerifyCultLibRevision` target in `Directory.Build.targets` exists to refuse a CultMath root at the wrong +revision, and here it stayed silent. Its `Condition` compares `ProjectReference` identities against a path +spelled with backslashes (`$(CultMathRoot)\packages\cultmath\...`). I infer that on Linux the identity no longer +matches that spelling, so the guard never runs. I have not verified this on Windows. + +This is fire control's or the build owner's to fix. A pin guard that turns itself off on the verification host +is a guard in name only. Hand it to Self as a spawn-worthy defect. + +--- + +# Part A. Mining + +## A.1 Status header (verified against git) + +Branch `codex/mining`, tip `283ce7dc`: 13 commits on `8bd25f6f`. `8bd25f6f` is an ancestor of fire control, +which has since moved **76 commits** to `b66ba524`. + +**What truly landed:** + +| Cut | Commits | What | +|---|---|---| +| map | `e729e6d6`, `670831c8`, `007771fe`, `e4d38f16`, `a9538b20`, `283ce7dc` | target, cut map, Q1-Q3 rulings, status | +| 1 | `22a54ccb`; fix `d9c887c1` | belts evaluated on demand; `Task.Run` belt threading deleted | +| 2 | `3b9f0f1d`, `c924bae0`; fixes `daa64560`, `34994732`, `93bfdebd` | `MiningTool`, `MineAsteroid` and the survey `Execute` deleted; `ChunkId`, `Zone` wear store, `ZonePack` key 6 | + +Production delta under `Assets/Scripts` (`git diff --numstat 8bd25f6f 283ce7dc`): **+181 / −250**. Tests add +**+681** (`MiningCut1Tests.cs` 240, `MiningCut2Tests.cs` 441). + +**Test counts.** The map says 274 tests after Cut 2 (`:493`), counted on its own `8bd25f6f` base. On the merged +tree the suite is 379, of which 17 are mining's. Every one passes (section 0). + +**Corrections to `docs/mining-cut.md` at `283ce7dc`:** + +1. **`:5` "Nothing here has landed" is false.** Cuts 1 and 2 landed at the SHAs above, and the map's own status + block (`:487-500`) says so. Replace it with a status line naming both cuts and the base. +2. **`:9` anchors at `8bd25f6f` are stale for every fire-control file.** At `b66ba524`, `FireControl.cs` is 1,614 + lines. `Splash` is gone. `Apply` is a switch over three functions (`ApplyDirectHit` `:680`, `ApplyBlastHit` + `:740`, `Detonate` `:1074`). `Silhouette` is at `:1278` and `PendingShot` at `:1464`. Every Cut 3 and 4 anchor + in the map (`:396-412`, `:669-721`) is re-anchored in A.3 below. +3. **`:24-27` "Fire control is mid-rewrite" is stale.** Cut 12.4 closed 2026-09-29 (`c50029d5`), and 362 tests + passed at that close (`docs/fire-control-cut.md` 12.4 status on `b66ba524`). +4. **Chunk detection: both the Codex map and v1 of this map got the ruling wrong.** + - The Codex map's Cut 3 (`:667-668`, `:693`) specifies `FireControl.ChunkDetectionRange` from the scanner's + `Range`, an `UnaidedChunkRange` fallback, and a test `AScannerExtendsChunkDetection`. Its Cut 7 + (`:834-836`) restores "the Resource Scanner as the chunk sensor". + - The Codex Q2 text (`:176-185`) makes a chunk visible unconditionally with PSensor 1, and defers detection to + the EW campaign. v1 of this map followed that text. + - The operator's correction, 2026-09-30: "We can just assign a reflectivity and cross section to each chunk + just like we do with entities, then their visibility depends on how much light is shining on them, no new + mechanism needed ... This is what I actually intended with my ruling about deferring detection." + - So chunk detection is **in this campaign**, through the existing reflectance rule (Cut 3). All three earlier + readings are superseded: + - the scanner as chunk sensor; + - unconditional visibility; + - PSensor fixed at 1. + - `ResourceScanner` stays parked. +5. **Q2's blast consequence (`:221-223`) was written against `Splash`.** Cut 12.4 replaced it with + `Detonate(Zone, float2 worldPlanar, float radius, float damage, DamageType)` (`FireControl.cs:1074` @`b66ba524`). + - `Detonate` takes no shooter. + - It walks `zone.Entities` only (`:1084`). + - So a proximity burst beside a rock does nothing to the rock today, and nothing could route its loot. + - This was new question Q10, ruled A on 2026-09-30: a blast covers every chunk it overlaps (Cut 4). +6. **`:135`, and the `SimpleCommodityData` comment it copies, are wrong about the retired keys.** Both claim keys + 6, 7 and 8 are retired. They are live: `ItemData` declares them for every item, `SpecificHeat` `Key(6)`, + `Conductivity` `Key(7)` and `Price` `Key(8)` (`ItemData.cs:291-298` @`b66ba524`), and `SimpleCommodityData` + inherits them. Only key 11 is retired on `SimpleCommodityData`. Keys 12 and 13 are still free. The + comment at `ItemData.cs:304` should say "key 11". +7. **Q7's recommendation (`:298-310`) predates the Q2 ruling.** Q2 (`:171-174`) says composition and loot tables + are **per field kind**, with debris rolling salvage through the same roll. Q7 recommends per-ore `Abundance` on + `SimpleCommodityData`, which has one table for every field kind. The two are unreconciled. Q7 is restated + below. Resolved 2026-09-30: Q11 (kinds are authored records yielding simple commodities) and Q7 A′. +8. **The `Target` blast-radius count (`:193-196`, "about 60 reads in 12 files") has grown.** + - At `b66ba524`, `git grep` finds 28 target reads in `ActionGameManager.cs` and 22 in `FireControl.cs`, where + the map counted 20 and 11. + - It finds 11 in `Entity.cs`, plus sites in the Unity weapon managers (`GuidedProjectileManager`, + `LightningGunManager`, `ConstantParticleWeaponManager`, `GuidedProjectile`) that the map does not list. +9. **The catalog changed after the mining probe.** + - `4f218e85` and `dd437580` (locomotion Cut 1) landed Longinus, Djinni and five thrusters in `Aetheria.cc` + after `8bd25f6f`. + - Catalog oid at `8bd25f6f`: `1862ddc4`. At `b66ba524`, and in all three merged trees: `2aa79c0a`. + - The census in section 0 replaces the map's weapon and commodity counts. + - Kinetic is 9 weapons, not 10 (`:313`). + - Ammo is priced 1000 (`:117`, `:421`). + - SRMM72 also authors `DamageSpread` (`:270`). + - The Q8 conclusion stands: no Corrosive or Ionizing weapon exists. +10. **Still owed from Cut 1 (`:508-510`):** a Unity compile of the `ZoneRenderer`/`AsteroidBeltUI` change, an + isolated Stryker rerun, and the operator's fly-through. Nothing after `d9c887c1` records any of them. +11. **Still owed from Cut 2 (`:493`):** Soul has not verified the two-commit fix (`34994732`, `93bfdebd`). The + map folds that check into Cut 3's Soul pass. Keep it named, so that it is not lost when Cut 3 is rewritten. + +## A.2 Merge path onto the fire-control-12 lineage + +| Probe | Result | +|---|---| +| `git merge-tree --write-tree origin/codex/fire-control-12 origin/codex/mining` | clean, tree `b7feb776` | +| `git merge-tree --write-tree 8a2db490 origin/codex/mining` (master+fc12) | clean, tree `7280568c` | +| mining merged, then ships merged on top | clean, tree `a7410174` | +| files touched by both mining (`8bd25f6f..283ce7dc`) and fire control (`8bd25f6f..b66ba524`) | none | + +The merged tree passes the full headless suite: 379/379, and 381/381 with ships (section 0). + +**No textual conflicts, and no shared file.** The semantic seam is not textual: mining Cut 3 onward edits the +code fire control rewrote after the mining map was written. That is handled by re-anchoring, not by merging. + +## A.3 Ordered cuts + +The Codex numbering is kept so the map's history still reads, and Soul fix slots are added. Cut order: + +| Cut | Nature | Blocked by | +|---|---|---| +| M0 | Placement: merge master, correct the map | fire-control-12 on master | +| M-Soul | Fix batch from the parallel Soul pass over Cuts 1-2 (content unknown here) | Soul's report | +| 3 | A chunk is a target, detected by reflected light; the field-kind record appears | M0 (Q14 A, Q16 B ruled) | +| 4 | A shot at a chunk goes through FireControl (re-anchored to 12.4); blasts cover chunks | 3; the fire-control no-target fuse ruling | +| 5 | Composition, loot, the mined lot, and the same-path test agent | 4 | +| ~~6~~ | **Deleted** (Q9 = C). The test agent moved into Cut 5. | — | +| 7 | Content: field kinds, ores, the Drill Bit, and new Corrosive and Ionizing weapons | 5; variants C4 for families authored as variants | + +Every mining question is ruled. + +### M0. Placement and map correction + +- **Repo/branch:** Aetheria, `codex/mining`, in a worktree. Do not use the operator's tree. +- **Deletes first:** none in code. +- **Keeps/moves/adds:** + - `git merge --no-ff master` once fire-control-12 is on master. + - In `docs/mining-cut.md`: apply corrections 1-11 of A.1. + - Replace Cut 3 and Cut 4 with the versions below. + - Strike `UnaidedChunkRange` from Cut 5's and Cut 7's tunables, and "Resource Scanner as the chunk sensor" from + Cut 7. + - Replace Q2's "detection is deferred to the electronic-warfare campaign" paragraph (`:176-185`) with the + operator's 2026-09-30 correction, verbatim. + - Record the 2026-09-30 rulings in the map's status header. + - Delete Cut 6 from the map; its history note points here. + - Mark the `:396-412` table as history, anchored at `8bd25f6f`. +- **Verification:** + - Yggdrasil, the same job that section 0 ran: + `ygg-verify.sh dotnet ''`. + Both roots are mandatory (shared precondition). + - The job mounts the Yggdrasil mirrors read-only with + `DOCKER_ARGS="-v /home/gamecultadmin/eureka-verify/repos:/repos:ro -v /home/gamecultadmin/eureka-verify/lfs-aetheria:/lfs:ro"`. + - It runs `git config --global --add safe.directory "*"` first. The `GIT_CONFIG_*` safe-directory setting that + `ygg-verify.sh` exports does not reach a local fetch from `/repos`. + Rule pinned: fire control and mining compose without a behaviour change on either side. Every pre-existing + test passes unchanged. + - The verify mirror has no LFS (the push from `F:\Projects\Aetheria` fails in the LFS pre-push hook on an + `ssh:` remote). Push from a `--shared --no-checkout` clone, and mount the two LFS blobs. This is recorded + so the next Hands does not rediscover it. + - Unity batchmode compile of the merged tree (Self, in a worktree, never with the operator's editor open on + the main tree). This is the Cut 1 renderer compile the map still owes. + - Operator: fly through a belt (the Cut 1 check the map still owes). Rocks orbit smoothly, and the minimap + and the map screen track them. +- **Subtraction:** docs only. + +### M-Soul. Fix slot for the parallel Soul pass + +- **Repo/branch:** `codex/mining`, after M0. +- **Content:** whatever the parallel Soul pass finds in Cuts 1-2, including the unverified `34994732` and + `93bfdebd`. This map does not predict it. Cut 3 does not start until this slot is closed or empty, because + Cut 3 builds on `Zone.Wear`, `ChunkExists` and `ChunkRadius`. + +### Cut 3. A chunk is a target, and is detected by reflected light + +- **Repo/branch:** Aetheria, `codex/mining`, after M-Soul. +- **Written for:** Q13 = A with the detection correction, Q14 = A, Q16 = B (all ruled). + +#### How detection works today (read at `b66ba524`) + +1. **Emitters write into `Entity.VisibilitySources`** (`Entity.cs:63`, a `Dictionary`). Every + entry is one source's contribution: + - `Reflector.cs:46`: `CrossSection × Zone.GetLight(Position.xz)`. This is the rule the operator named. + - Black-body radiation: `Entity.cs:1269`. + - Weapons: `InstantWeapon.cs:257`, `ConstantWeapon.cs:166`. + - Thrusters: `Thruster.cs:110-111`. + - Sensor pings: `Sensor.cs:117`. + - `Visibility.cs:44` and `Radiator.cs:121`. + + Sources decay each tick (`Entity.cs:1083-1087`, `VisibilityDecay` 0.5). +2. **`Entity.Visibility`** (`Entity.cs:136`) is the sum of the sources. +3. **Each `Sensor` behaviour on an observer** loops over every entity in the zone, every tick + (`Sensor.cs:157-189`). + - It adds a gain into `EntityInfoGathered[target]` (`Entity.cs:64`, a `ReactiveDictionary`). + - Passive gain is `target.Visibility × Sensitivity × SensitivityCurve(angle/π) × dt / dist` (`:179-184`). + - Ping gain is `target.Visibility × Sensitivity × PingBoost × dist`, once per ping (`:170-175`). + - It then decays: `next *= 1 − TargetInfoDecay × dt` (`:186`; `TargetInfoDecay` 0.5 in shipped + `Settings.asset`). +4. **Crossing `TargetDetectionInfoThreshold`** (0.1) adds the target to or removes it from `VisibleEntities`, via + the `ObserveReplace` subscriber at `Entity.cs:237-253`. +5. **Fire control reads both.** `PFire` gates on `VisibleEntities.Contains` (`FireControl.cs:173`) and prices + `PSensor` from the info value (`:179`). The same info also gates the armour and gear reveal thresholds + (0.5 and 0.8) through `IsRevealed`. +6. **Light** is `Zone.GetLight(float2)` (`Zone.cs:369` @`283ce7dc`). It sums `PowerPulse(d / LightRadius, 8)` + over every sun within its `LightRadius`. Beyond every sun's radius, light is 0. + +#### How a chunk plugs in, without becoming an entity + +Two things are needed: +- **A chunk's visibility**, `V(chunk, t)`: `cross-section(chunk) × GetLight(pose(chunk, t))`. This is + `Reflector.cs:46`'s rule, applied to a pose that is already a pure function of time (Cut 1). No stored source, + and no decay: a chunk has only the reflected term, because rocks carry no thrusters, weapons or heat + (black-body emission is out of scope, per the operator's correction). +- **An observer's info on a chunk.** The gain rule is the existing one. Q14 decides how it integrates over time. + Recommended: the closed-form equilibrium of the per-tick rule, evaluated on demand, with no per-tick state. + +**No adapter, and why that is the goal.** The adapter would be a pseudo-entity per chunk, or an `IDetectable` +wrapper, so that `Sensor.Execute`'s loop over `Zone.Entities` sees rocks. That is rejected option C of the Codex +Q2 in a costume: +- It puts every chunk into every sensor's per-tick loop. The map measured 184 ns per observed body per tick, + which is 149 µs per sensor-bearing ship for the 809-chunk probe zone. +- It puts chunks into `EntityInfoGathered`, whose `ObserveReplace` subscriber builds `VisibleEntities`, + `VisibleEnemies` and `VisibleFriendlies`. Every consumer of those collections would then have to learn that + some "entities" are rocks. + +The shared owner has to be **the rule, not the container**. The cut extracts the gain terms at `Sensor.cs:170-184` +into one static function, and both paths call it: +- the entity loop, per tick, unchanged in behaviour; +- the chunk query, on demand. + +Nothing about a chunk is stored per observer. + +#### Deletes first + +- The Codex scanner-as-sensor plan: no `ChunkDetectionRange`, no `UnaidedChunkRange`, and no scanner read + anywhere in the target path. +- The v1 placeholders: unconditional chunk visibility, and PSensor fixed at 1. +- Optional, Self's call: the parked `ResourceScanner` behaviour still runs a per-tick `Update` over three stats no + code reads (`ResourceScanner.cs:35-58` @`283ce7dc`). Delete the `Update` and the properties, and keep + `ResourceScannerData` authored. + +#### Adds + +- **`Sensor.Gain(...)`**, a static function extracted verbatim from `Sensor.cs:170-175` (ping) and `:179-184` + (passive). Inputs: target visibility, sensitivity, the curve at the angle, distance, dt, and whether this is a + ping. `Sensor.Execute` calls it. This is a pure extraction. +- **`FieldKindData`**, a new catalog document type (Q11: authored catalog records). Cut 3 introduces it with only + what detection needs: + - `Name` (`[CultName]`); + - `CrossSection` (float): reflectivity per schematic cell of chunk area; + - `GenerationWeight` (float), for Q16. + + Cut 5 adds the yield entries. Only one kind exists at first, "Asteroid". +- **The belt names its kind.** `AsteroidBeltData` gains a nullable `CultRecordRef Kind`, at the next + free key after `Asteroids` `Key(9)` (`ZoneData.cs:101-105` @`283ce7dc`). + - `ZoneGenerator` writes it at generation, through one function `FieldKinds.Assign(belt key, catalog)`: a + `StableHash` pick weighted by `GenerationWeight`. + - Under Q16 = B, a belt loaded without a kind is assigned by the same function on first load and written back. + One derivation serves both the generation path and the load path. +- **`Zone.ChunkVisibility(ChunkId)`** = `kind.CrossSection × π(ChunkRadius / SchematicCellSize)² × GetLight(ChunkPose.xy)`. + - Area in schematic cells keeps the numbers comparable to hull reflectors. The census has Longinus at 500-150 + over 66 cells and Djinni at 2500-500 over 166, so about 2-15 per cell. + - A worn chunk shrinks and dims. A broken chunk has radius 0, so it is invisible. +- **`Entity.ChunkInfo(ChunkId)`**: the info this observer has on the chunk, from its active `Sensor` behaviours + through `Sensor.Gain`, integrated per Q14. Visible means `ChunkInfo > TargetDetectionInfoThreshold`. +- **`TargetRef`**: a readonly struct holding `Entity Entity` and `ChunkId? Chunk`, with value equality and `IsNone`. + It lives beside `ChunkId` (`Zone.cs:527` @`283ce7dc`). +- **`Zone.ChunksNear(float2 position, float range, List into)`**: the one chunk query. + - Belts whose annulus cannot reach are skipped. + - The rest are evaluated through `ChunkPose` (`Zone.cs:285`). + - Broken chunks are skipped through `ChunkExists` (`:231`). + - It knows nothing about detection. Callers filter by `ChunkInfo`. +- **`Zone.ChunkVelocity(ChunkId)`**: analytic velocity plus the parent `Orbit.Velocity`. +- **`Entity.SetTarget(TargetRef)`**: the one writer. The player's handlers and the Cut 5 test agent both call it + (target invariant 3, "set by one writer path that both the player and the AI call"). Today every handler writes + `Target.Value` directly (`ActionGameManager.cs:377,385,395,403`), so there is no one writer to share. +- **Reach (Q13 = A):** cycling and any programmatic chunk pick use `ChunksNear(position, longest active weapon + Range.Max)`, then filter to chunks the observer can see. + +#### Per-file changes (`b66ba524` unless marked) + +- `Sensor.cs:170-184`: call `Sensor.Gain`. Behaviour must stay identical; the whole existing suite is the check. +- `Entity.cs:46`: `Target` becomes `ReactiveProperty`, written only through `SetTarget`. +- `Entity.cs:201`, `:227`: entity removal clears the slot only when it holds that entity. +- `Entity.cs:229`: `TargetedBy` fires for entities only. +- `Entity.cs:233`: `TargetedByCount` counts entity targets only. +- **`Entity.cs:298-299`:** today it clears a target missing from `EntityInfoGathered`, which would clear every + chunk target on the next pass. For a chunk, it clears when `ChunkInfo ≤ TargetDetectionInfoThreshold`, the same + "lost track" rule expressed through the chunk path. A broken chunk has 0 visibility, so it is lost the same way. +- `Entity.cs:317`: `TrySelectTargetItem` is entity-only. +- `Entity.cs:1078`: `TargetRange` reads the chunk position for a chunk. +- `ActionGameManager.cs:365-377` (reticle pick): pick the chunk under the reticle from `ChunksNear` at the + reticle, if visible. +- `ActionGameManager.cs:381-388` (`TargetNearest`): stays enemies-only. It drives lock. +- `ActionGameManager.cs:390-404` (next and previous): include visible chunks within Q13's reach. +- `ActionGameManager.cs:412-414` (Cycle Target Item): entity-only. +- `ActionGameManager.cs:582`, `:745`, `:769`, `:1046`, `:1224`, `:1239`, `:1266-1267`, `:1355-1373`: `.Entity` + reads, or the chunk position for the indicator. +- `ActionGameManager.cs:1285`: `UpdateFireControlDebug` takes `TargetRef`. +- `Weapon.cs:101` `StanceAllowsFire`: true for a chunk. `Weapon.cs:110` `ArcAllowsFire`: position through the ref. +- `LockWeapon.cs:86`: a chunk never locks (Q12 = A, ruled). +- `TurretController`, `Combat.cs`, `Minion.cs`, `Ship.cs`, `EntityInstance.cs`, `PropertiesPanel.cs`, + `FieldDriver.cs`, `ActionBarSlot.cs`: `.Entity` and null checks. + +#### Authority map + +- **Owners:** + - `Entity.Target` is the one slot, and `SetTarget` is its one writer. + - `Sensor.Gain` is the one detection gain rule. + - `FieldKindData` owns a chunk's reflectivity. + - `FieldKinds.Assign` owns which kind a belt is. +- **Inputs:** player handlers and the test agent; sensor stats; chunk pose and radius; sun light. +- **Outputs:** the current `TargetRef`; `ChunkVisibility`; `ChunkInfo`. +- **Derived state:** + - `ChunkVisibility` and `ChunkInfo` are pure functions of time and are never stored. + - `TargetItem` is entity-only. + - `TargetRange`. +- **Forbidden writers:** + - any second target field; + - any direct `Target.Value =` outside `SetTarget`; + - any chunk in `Zone.Entities` or `EntityInfoGathered`; + - any per-tick chunk detection loop; + - any chunk-specific copy of the gain arithmetic; + - any unconditional visibility for chunks. +- **Shared paths:** reticle, cycling, the test agent, dock and undock re-activation, and belt generation and load + (through `FieldKinds.Assign`). +- **Deletion line:** no `ReactiveProperty Target` remains; no `Target.Value =` outside `SetTarget`; no + gain arithmetic outside `Sensor.Gain`. + +#### Verification + +Yggdrasil, the M0 command with `--filter FullyQualifiedName~MiningCut3`, then the full suite. + +| Test | Rule it pins | +|---|---| +| `SensorGainExtractionChangesNothing` | The existing detection tests, and a recorded `EntityInfoGathered` trace over N ticks for a fixed scene, are byte-equal before and after the extraction. | +| `ChunkInfoMatchesAnEntityHeldStill` | Q14's parity. An entity with a `Reflector` of the same cross-section, at the chunk's position and held still, converges under the per-tick loop to the value `ChunkInfo` returns (tolerance from the discrete fixed point). **One rule, two integrators.** | +| `AChunkInDarknessIsNotVisible` | A chunk outside every sun's `LightRadius` has `ChunkVisibility` 0 and cannot be targeted or fired on. | +| `ABrighterOrBiggerChunkIsSeenFurther` | Monotone in cross-section, radius and light. | +| `AWornChunkDims`, `ABrokenChunkIsInvisible` | Visibility follows `ChunkRadius`. | +| `CyclingReachIsTheLongestActiveWeaponRange` | Q13. A visible chunk beyond it is not offered. | +| `AChunkTargetSurvivesTheInfoPassWhileSeen` | The negative for `Entity.cs:298-299`. It fails on the naive retype. | +| `AChunkTargetIsDroppedWhenItGoesDark` | The lost-track rule, through the chunk path. | +| `TargetRefEquality` | Value equality; a chunk never equals an entity. | +| `ChunkTargetIsNeverLocked` | Q12 = A. | +| `FieldKindAssignmentIsOneFunction` | Generation and first-load assignment give the same kind for the same belt key; the kind is written back once. | +| every existing fire-control and sensor test | Pass unchanged. This is the negative that ship detection and targeting did not move. | + +Also: +- Negative greps: `git grep -n "ReactiveProperty Target\b"` and `git grep -n "Target.Value ="` outside + `Entity.SetTarget` are empty. +- Stryker over `Sensor.Gain`, `Zone.ChunkVisibility`, `Entity.ChunkInfo`, `ChunksNear` and `TargetRef`. +- Census before and after: `AsteroidBeltData` is a run-store type and is absent from the catalog, and + `FieldKindData` shows one record once Cut 7's content lands. Until then, Cut 3 ships a test fixture kind and a + one-record catalog write through `AetherDb`, flagged for operator review. +- Unity compile: Self. +- Operator: + - target a lit rock with the reticle, and cycle through rocks and ships; + - fly into a belt far from the sun and confirm its rocks cannot be picked; + - dock and undock with a rock held. + +#### Subtraction estimate + +About 0 removed (optionally about 15 for the scanner `Update`). About 140 added: +- `Sensor.Gain` extraction nets about 0; +- `FieldKindData` plus assignment, about 40; +- `ChunkVisibility` and `ChunkInfo`, about 30; +- `TargetRef`, `ChunksNear`, `SetTarget`, about 70. + +Plus about 95 mechanical site edits. One new catalog document type (`FieldKindData`), and one nullable +run-store slot (`AsteroidBeltData.Kind`). + +### Cut 4. A shot at a chunk goes through FireControl, and blasts cover chunks (re-anchored to 12.4) + +- **Repo/branch:** `codex/mining`, after Cut 3. +- **Written for:** Q10 = A, Q12 = A (both ruled). +- **Lands after:** the fire-control no-target fuse ruling (`docs/fire-control-cut.md`, 12.4 status, "a fused + weapon fired with no target bursts at the shooter's own position", `FireControl.cs:389`). That ruling's fix + edits the same `target != null` branches this cut retypes (`:367`, `:372-378`, `:389`). If the ruling is still + open, this cut preserves today's behaviour for the no-target case and says so in its commit. + +**Deletes first:** none. `PendingShot.Target` (`:1468`) and `ShotOutcome.Target` (`:1598`) become `TargetRef`. + +**Adds:** +- **`Silhouette.Disc(float radiusCells, float precision)`.** The shared integrator is extracted from `Silhouette` + `:1302-1308` (the `Phi` sum over merged intervals, and `span`). The hull path and the disc path call one + function. No second `Phi` summation exists. +- **A chunk pass inside `Detonate`** (Q10 = A). After the entity pass (`:1084-1149`): + - `ChunksNear(worldPlanar, radius + max chunk radius)`. + - Each covered chunk takes `damage × CircleCircleOverlap(blast, chunk) / (π r_blast²)`, the same normaliser the + cell shares use (`:1092`). + - `CircleCircleOverlap` is the closed-form lens area, about 12 lines, beside `CircleSquareOverlap` (`:1153`). + - Each share goes to `Zone.Wear`. + - **One area rule:** a blast's disc is divided over whatever it covers, whether cells or chunks. Conservation + holds across both. +- **`Detonate` gains an optional loot sink**, the detonating shot, so the shooter receives the ore (Q10 = A). + - A sourceless blast passes null. None exists today, but a mine is one (F12-6). A null-sink blast wears chunks + and yields nothing. + - This is the only new parameter. `Detonate` still reads no host (12.4's rule). + +**Per-file changes** (`FireControl.cs` @`b66ba524`): +- `PFire` `:161-181`: + - For a chunk, the visibility gate (`:173`) is `ChunkInfo > TargetDetectionInfoThreshold`, and `info` (`:179`) + is `ChunkInfo` (Cut 3). **This replaces v1's "no gate, info 1".** + - `PSensor` prices a chunk exactly as it prices a ship at the same info. + - A `LockWeapon` is gated out (`:175`, Q12 = A). The arc stays (`:176`). +- `Forecast` `:206`, `HitProbability` `:228`, `Inspect` `:243-283`: the disc silhouette. `Inspect`'s `Visible` and + `Info` come from the chunk path. +- `PredictedIntercept` `:133`, `TravelDirection` `:307`: position and velocity through the ref. `Bearing` `:316` + is not called for a chunk, because a disc has no facing. +- `DeviationProbability` `:336-341`: the live chunk position is `Zone.ChunkPose` at `now`. +- `Fire` `:355-418`: freezes the ref. +- `Step` `:439`: `targetGone` includes `!ChunkExists`. +- `CommitProbability` `:506-520`: for a chunk, the disc (no `GetData(Hull)` at `:516`). +- `Commit` `:550-600`: for a chunk hit, no `Lane` (`:563-578`) and no shield block (`:585-592`). `Cell`, `Bearing` + and `Lateral` stay zero in `MakeOutcome` (`:1028`). +- `Apply` `:651-678`: + - Null fuse on a chunk: `Zone.Wear(chunk, Damage)` (`Zone.cs:260` @`283ce7dc`). + - Contact or delayed fuse on a chunk: no `ApplyBlastHit`, which needs a hull (`:747-760`). `Detonate` at the + chunk's centre at arrival. The pose is a pure function of time, so the point is exact. + - Proximity: `Detonate` as today, now with the chunk pass. + - Contact or delayed on a ship: `ApplyBlastHit` as today, and its `Detonate` now also covers nearby chunks. +- Unity presentation: `EntityInstance.cs` (4 target reads) and the four weapon managers aim at the chunk's + rendered position. Presentation only. + +**Authority map:** +- **Owner:** `FireControl`, unchanged. `Detonate` stays the one area owner. +- **New inputs:** chunk pose, velocity, radius and info, through `Zone` and `Entity.ChunkInfo`. +- **When blast damage to chunks is decided:** at detonation. Each covered chunk is judged where it is at arrival, + which is 12.4's own bystander rule (`ApplyBlastHit`'s comment at `:773-777`: "every bystander is judged against + where it actually is at arrival"). A direct hit on a chunk is still decided at `Commit`. +- **Forbidden writers:** + - any chunk hit test outside `Commit`, except `Detonate`'s area pass; + - any second area-share formula; + - any second `Phi` integrator; + - `Detonate` reading a host. +- **Shared paths:** player trigger, the Cut 5 test agent, AI `Activate` (`Combat.cs:121`), and turrets (which + never hold a chunk). Any blast from any source goes through `Detonate`. +- **Deletion line:** none. + +**Verification** (Yggdrasil, `--filter FullyQualifiedName~MiningCut4`, then the full suite): + +| Test | Rule it pins | +|---|---| +| `AChunkShotUsesTheSameFactors` | `Accuracy`, `PSpread`, `Sigma` and `PSensor` equal a ship shot's at equal range, precision and info. One fire path. | +| `AnUnseenChunkCannotBeFiredOn` | The gate is the detection rule, not a constant. | +| `DiscMassIsTheClosedForm` | `POnHull = Φ(R/σ) − Φ(−R/σ)`: the shared integrator. | +| `HullSilhouetteUnchangedByTheExtraction` | Every existing `Silhouette` test and the `ProbeC` equivalence still pass. The negative for the extraction. | +| `AChunkThatBreaksMidFlightIsAMiss` | `Step`'s gone rule. | +| `ChunkHitsWearTheChunk`, `AHitOnABrokenChunkChangesNothing` | Wear; re-pins `34994732` through the real path. | +| `ABlastConservesAcrossCellsAndChunks` | The shares delivered to cells plus chunks, plus the uncovered area, equal the damage. Same lattice style as 12.4's 1,944-disc conservation probe. | +| `ABlastBesideAnUntargetedRockWearsIt` | Q10 = A. | +| `CircleCircleOverlapIsTheLens` | Closed form, with the tangent, contained and disjoint cases (12.4's F1 tangent lesson). | +| `LaunchersCannotFireOnAChunk` | Q12 = A. | +| `ChunkDiceAreTheShotsOwn` | The same galaxy and shot id give the same outcome. | + +Also: Stryker over the new branches. Operator: shoot a lit rock with each damage type; it shrinks and breaks, +and misses at range look plausible. + +**Subtraction estimate:** about 5 removed (the integrator extraction) and about 115 added (the chunk pass and the +lens area add about 35 over v1). + +### Cut 5. Composition, the loot roll, the mined lot, and the same-path test agent + +- **Repo/branch:** `codex/mining`, after Cut 4. +- **Written for:** Q4 A, Q5 A, Q6a A, Q6b A, Q7 A′, Q9 C, Q10 A and Q11, all ruled on 2026-09-30. + +**Deletes first:** +- `BodyData.Resources` (`ZoneData.cs:78-79` @`283ce7dc`, key 4) becomes a retired-key comment. Its only reader, + `MineAsteroid`, died in Cut 2. +- `Agents/Tasks/Mining.cs` and its `.meta` (@`283ce7dc`: 19 lines, no reader, not persisted per `AgentTask.cs:11`, + and not registered in any union). AI mining is deferred (Q9 = C), and the campaign that picks it up writes its own + task. + - Leave the `TaskType.Mine` enum member (`AgentTask.cs:27`). Removing it would renumber the members after it for + no gain. + +**Adds:** +- **`FieldKindData` gains `Entries`**, a `List`. Each `FieldYield` is: + - `Commodity`: a `CultRecordRef` (simple commodities only, Q11); + - `Abundance` (float); + - `Affinity`: an object list of `(DamageType, float)` pairs, absent meaning 1. + + **Every list here is an object list, not a dictionary.** Variants address object-list elements by id and replace + dictionaries whole (variants Q7). So a "rich belt" variant can override one entry's abundance. +- **`Zone.ChunkComposition(ChunkId, Span<…>)`**: derived. `Abundance_o × exp(ChunkCompositionVariance × g(StableHash(field key, index, o)))`, + normalized, from the belt's kind (Cut 3). Never stored. +- **The loot roll (Q4 A, Q5 A)**, for one hit with damage *D* on a chunk of size *s*: + - units = `min(D, remaining HP) / HP(s) × Yield(s)`, the floor plus one Bernoulli draw on the remainder; + - the weight of ore *o* = `Affinity_o(d) × Comp(o)^(1/Depth)`, with `Depth = 1 + P × MiningDepthPerPenetration`; + - one draw per unit. +- **Where each roll happens:** + - **Direct hit:** inside `Commit`, after the hit draw, from the shot's own generator (`FireControl.cs:540` + @`b66ba524`). Frozen into `ShotOutcome.Loot` (commodity and count pairs). + - **Blast (Q10 = A):** inside `Detonate`'s chunk pass, per covered chunk, with that chunk's share as *D*. The + draws come from a generator seeded by the same `(zone, shot)` pair as the shot's commit dice, plus a fixed + blast salt, walked in `ChunksNear` order (belt key, then index). That keeps the dice the shot's own, and + deterministic. + - **This amends target invariant 5.** The old wording was "the loot is decided at Commit". The new wording: + "loot is decided by the owner that decides the damage, from the shot's own dice". That is `Commit` for a direct + hit, and detonation for a blast, following 12.4's bystander rule. I state it here, not as a new fork: it follows + from Q10 = A plus 12.4's ruled model. +- **`ItemManager.ExtractedLot(zone, field, commodity)`** (Q6b A): find-or-mint, with a source-to-lot index derived + at construction and never persisted. +- **`Extracted`** (`Provenance.cs:116` @`b66ba524`) gains `Body` at key 2 (`CultRecordRef`, nil on older + records). +- **`Lot` moves** from `CraftedItemInstance` (`ItemInstance.cs:38`) to `ItemInstance`, keeping key 11. Coordinate + with variants C4 (A.6). +- **The deposit (Q6a A):** units go straight into the shooter's cargo at arrival (a direct hit) or at detonation (a + blast), through `TryStore` (`Entity.cs:1805`). A full hold loses the overflow. +- **Tunables:** `MiningDepthPerPenetration` on `GameplaySettings`; `AsteroidYield` (`ExponentialLerp`) and + `ChunkCompositionVariance` on `PlanetSettings`. None may be CultMath-typed while settings are Unity-serialized. +- **The same-path test agent (Q9 = C, replacing Cut 6).** A headless test drives a ship through exactly the + player's calls: + 1. `Entity.SetTarget(TargetRef(chunk))` (Cut 3); + 2. `weapon.Activate()` (the call `Combat.cs:121` and the player's trigger make); + 3. `Zone.Update` ticks. + + There is no `Agent`, no `AgentTask` and no `Minion` state. It proves the path an AI will later drive, without + building the AI. + +**Per-file changes** (`b66ba524`): +- `FireControl.Commit`, `ApplyDirectHit` and `Detonate`: the roll and the deposit, as above. +- `Entity.cs:1856`: the stack merge matches `Data.Key` only today; it must also match `Lot`. +- `Entity.cs:1934`: the split constructs a `SimpleCommodity` without `Lot`; it must copy `Lot`. +- `SavedGame.cs:113`: the ledger roots are `.OfType()` today; they become every + `ItemInstance` with `Lot != 0`. +- `ItemManager.cs:72`: `GetLot(CraftedItemInstance)` takes `ItemInstance`. + +**Authority map:** +- **Owners:** + - `Commit` decides direct-hit loot, and `Detonate` decides blast loot, each from the shot's dice. + - `ItemManager` mints lots. + - `EquippedCargoBay` places units. + - `FieldKindData` owns what a field yields. + - `FieldKinds.Assign` owns which kind a belt is. +- **Inputs:** frozen damage, damage type and penetration; the chunk's composition and remaining hit points at the + deciding moment. +- **Outputs:** `ShotOutcome.Loot`, units in cargo, ledger lots. +- **Derived state:** composition (never stored); the source-to-lot index. +- **Forbidden writers:** + - any loot draw outside `Commit` and `Detonate`'s chunk pass; + - any mined `SimpleCommodity` with `Lot == 0`; + - any shared random stream; + - per-ore tables on `SimpleCommodityData`; + - any extraction call that bypasses `FireControl`. +- **Shared paths:** the player's trigger and the test agent (identical calls); save GC; Continue, where the index is + rebuilt. +- **Deletion line:** `BodyData.Resources` is retired, and `Mining.cs` deleted, before the roll is added. + +**Verification** (Yggdrasil, `--filter FullyQualifiedName~MiningCut5`, then the full suite): + +| Test | Rule it pins | +|---|---| +| `RightDamageTypeRaisesTheOresShare` | The Q4 table, as a fixture on a kind record. | +| `PenetrationFlattensTowardRareOre`, `ZeroPenetrationReadsTheRawComposition` | Q4's depth term. | +| `AChunkYieldsItsAuthoredTotalWhateverBreaksIt` | Invariant 7, summed over direct hits and blasts from different weapons. | +| `DirectHitLootIsDecidedAtCommit` | Loot frozen in `ShotOutcome`, performed at arrival. | +| `BlastLootIsDecidedAtDetonationFromTheShotsDice` | The amended invariant 5. The same galaxy and shot give the same ore. | +| `ABlastPaysTheShooter` | Q10 = A. | +| `ASourcelessBlastWearsButYieldsNothing` | The null loot sink. | +| `MinedUnitsCarryAnExtractedLotNamingZoneAndField` | Provenance, including `Extracted.Body`. | +| `UnitsFromOneSourceStack`, `UnitsFromTwoSourcesDoNot` | Q6b A, and the negative for `Entity.cs:1856`. | +| `ASplitStackKeepsItsLot` | The negative for `Entity.cs:1934`. | +| `AMinedStackSurvivesSaveGc` | The negative for `SavedGame.cs:113`. It fails on the old roots. | +| `AFullHoldLosesTheOverflow` | Q6a A. | +| `CompositionIsStableAcrossContinue` | Derived, never stored. | +| `TwoKindsRollDifferentTables` | Per field kind (Q2, Q11). | +| `ATestAgentMinesThroughThePlayersPath` | Q9 = C. After N seconds: ore in the hold, every unit's lot `Extracted` from that belt, and every unit traceable to a `ShotOutcome` or a detonation. The negative: grep for any call to `Zone.Wear` or `ItemManager.ExtractedLot` outside `FireControl.cs`. | + +Also: +- Stryker over the formula, the blast pass and the mint. +- Census: the new `FieldKindData` slots read as `defaulted_missing_slot` until Cut 7. +- Operator: mine one lit rock with a Thermal gun and one with a Kinetic gun, and read the cargo and its brand rows. + +**Subtraction estimate:** about 21 removed (`Resources` 2, `Mining.cs` 19). About 190 added: the roll, the blast +loot, the mint, the kind entries and the test agent. The test agent replaces Cut 6's ~90 lines. + +### Cut 6. Deleted (Q9 = C) + +AI mining is deferred. Its same-path proof is the test agent in Cut 5. The Codex Cut 6 (`:800-822` @`283ce7dc`), +its locomotion Cut 4 dependency and its inheritance of F12-5 all leave this campaign. + +The campaign that builds AI mining starts from: +- `Zone.CreateAgent` (`Zone.cs:126-133` @`283ce7dc`); +- the `Minion` transitions (`Minion.cs:10-21` @`b66ba524`); +- the one writer `Entity.SetTarget`; +- `Combat.cs:121`'s `Activate`. + +### Cut 7. Content (operator review sheet, one catalog commit) + +This is a content cut, applied through an `AetherDb mining-content [apply]` command. It is a dry run by default, +and the precedent is `targeting-catalog`. The operator reviews a sheet before `apply`. The command honours variants +Q1a: no plain `Upsert` at a variant key. + +**1. Ores.** The 14 legacy ores missing from live, restored with their legacy mass, specific heat, conductivity and +category (values are in `legacy-0414`, per the Codex map's `:830-832`): +- Minerals: Crystal, Silicon, Carbon, Nitrogen; +- Metals: Iron, Titanium, Copper, Lead; +- Compounds: Ethanol, Hydrocarbons, Acid, Oxygen, Carbon Dioxide; +- Organics: Plant Matter. + +Simple commodities only (Q11). + +**2. Field kinds.** One `FieldKindData` "Asteroid" record: +- `CrossSection` per cell: recommend about 5. That is dull rock, below Longinus's 7.6 and Djinni's 15 per cell at + their maximum reflector values (section 0). +- `GenerationWeight` 1. +- `Entries` over the live and restored ores, each with an `Abundance` and `Affinity` over **all six damage types**, + now that Q8 = B makes every type reachable. + +Further kinds ("rich", "ice", debris) are the operator's to add. Each is a variant of "Asteroid" overriding entries +by id, once variants C4 lands. + +**3. Drill Bit (Q1 A).** +- A `ConstantWeaponData` beam at Kinetic, with high penetration and range 5-20. +- DPS, efficiency and penetration are from legacy `MiningToolData` (DPS 1-2.5, efficiency 2-5, penetration 1-5, + range 5-20). +- No live weapon uses `ConstantWeaponData` (census), so the Drill Bit is also the first live constant weapon. The + operator's check covers the beam's presentation. + +**4. Corrosive and Ionizing weapons (Q8 = B, against v1's recommendation).** + +*The balance fact this rests on (read at `b66ba524`):* **damage type does nothing in combat today.** +- `WeaponData.DamageType` (`Weapon.cs:22-23`) is frozen into the shot (`FireControl.cs:1477`) and passed to + `Shield.CanTakeHit` and `TakeHit` (`Shield.cs:166`, `:186`). Both ignore their `type` argument. +- Armour absorption (`ArmorAbsorb`, `ItemAbsorb`) and `Detonate` take no type. +- The only other reader is `AbsorbEvent`, which is presentation (`CapabilityEvents.cs:28`). +- Mining's affinity (Cut 5) becomes **the first simulation reader of damage type.** +- Therefore a new-type weapon's combat power is fixed entirely by its non-type stats. Its identity shows only in + mining and presentation. + +*Anchors (census, merged tree):* +- Kinetic 9, Electric 3, Optical 3, Thermal 3. +- Sustained single-target DPS (`Damage × Count / Cooldown`, min to max quality) for the auto class: + - Autocannon 6; + - ClearPath 20-180; + - Spectra 40-340; + - ColdFire 16-160; + - CShot RainbowLite 33-600. +- Burst weapons (DeathCluster, FastBlast+-) are magazine-limited and are not comparable by DPS. + +*Records proposed.* One per type. Each is a minimal override of an existing weapon, so its combat envelope equals +its base's by construction, and balance risk is zero on day one. + +| New record | Type | Base (class, hardpoint) | Overrides | Why this base | +|---|---|---|---|---| +| (name: operator's) | Corrosive | ClearPath (`AutoWeaponData`, Ballistic) | `Name`, `DamageType` | Short-range stream: a sprayer. Range 250-600, DPS 20-180, cheap (price 50,000, mass 50). | +| (name: operator's) | Ionizing | Spectra (`AutoWeaponData`, Energy) | `Name`, `DamageType` | Beam-like optical: a particle beam. Range 800-1750, DPS 40-340. | + +Optional second step, flagged for review and not default: give the Ionizing record `Penetration` 0.25 (Autocannon's +value) with damage × 0.8 to pay for it. That makes it the rare-ore tool through Q4's depth term. It changes combat, +because penetration drives 12.3's lanes, so it needs the operator's eye and F12-2's retune. + +*Presentation:* the effect prefab and icon stay the base's until art exists. The Corrosive and Ionizing presentation +look is the operator's call, and so are the names. + +*Variants:* both records are ideal variants: a base plus two overrides, one of them nested in +`Behaviors[weapon].DamageType`. A nested override needs element ids (C2), and Aetheria adopting them needs C4. +- If Cut 7 runs before C4: author them as plain records, and list them in C4's family re-author pass. +- If it runs after: author them as variants. + +Either way, nobody copies a record by hand twice. + +**5. Tunables:** values for `AsteroidYield`, `ChunkCompositionVariance` and `MiningDepthPerPenetration`. + +**Verification:** +- `census` and `dangling` before and after. +- Cut 5's loot fixture runs against the live catalog. It asserts that every ore in every kind is reachable, meaning + some live weapon's damage type has an affinity above 1 for it. +- `EveryDamageTypeHasALiveWeapon` pins Q8 = B. +- Operator: review the sheet; mine with the new Corrosive and Ionizing weapons; feel the Drill Bit. + +## A.4 Subtraction ledger (estimate, whole campaign from here) + +| Cut | Removed | Added | Formats and targets | +|---|---|---|---| +| 1-2 (landed) | 250 | 181 src, 681 test | union 26 retired; `ZonePack` key 6 | +| 3 | ~0 (optionally ~15, the scanner `Update`) | ~140, plus ~95 mechanical site edits | `Target` retyped; `FieldKindData` document type; `AsteroidBeltData.Kind` (nullable) | +| 4 | ~5 (integrator extraction) | ~115 | `PendingShot` and `ShotOutcome` target retyped; `Detonate` gains a chunk pass and a loot sink | +| 5 | ~21 (`Resources`, `Mining.cs`) | ~190 | `FieldKindData.Entries`; `Extracted` key 2; `Lot` moves to `ItemInstance` key 11; `BodyData` key 4 retired | +| 6 | deleted | 0 | none | +| 7 | 0 | content | 14 ores, 1 field kind, the Drill Bit, 2 weapons | + +No new package, assembly or executable target. The net is additive from Cut 3 on. Each addition buys a named +capability: detectable, targetable chunks, the loot roll, blast mining, and provenance. Deleting Cut 6 removed +about 90 lines of AI code from the plan. + +## A.5 Operator questions (mining) + +### Ruled, 2026-09-30 (recorded, not reopened) + +| Q | Ruling | Where it lands | +|---|---|---| +| Q4 | A, the formula as written | Cut 5 | +| Q5 | A, per hit | Cut 5 | +| Q6a | A, straight into cargo; overflow lost | Cut 5 | +| Q6b | A, one lot per (zone, field, commodity) source | Cut 5 | +| Q7 | A′, a per-kind entry list (commodity, abundance, affinity), derived, never stored per chunk | Cuts 3 and 5 | +| Q8 | **B**, author Corrosive and Ionizing weapons (against v1's recommendation) | Cut 7 §4 | +| Q9 | **C**, AI mining deferred; the path is proven by a test agent (against v1's recommendation) | Cut 5; Cut 6 deleted | +| Q10 | A, a blast covers every chunk it overlaps; the shooter gets the loot | Cuts 4 and 5 | +| Q11 | field kinds are authored catalog records; tables yield simple commodities only | Cuts 3, 5 and 7 | +| Q12 | A, launchers cannot mine | Cuts 3 and 4 | +| Q13 | A, reach is the longest active weapon range. Detection correction: chunks are detected by the existing reflectance rule, not deferred to EW | Cut 3 | + +Earlier rulings: Q1 A, Q2 A (one slot, two kinds; field-kind generalization), Q3 A. + +### Open, two forks raised by the Q13 correction + +**Q14. How does a ship's knowledge of a rock build up over time?** (Blocks Cut 3's `ChunkInfo`.) + +*Context.* Your correction puts chunks on the same detection rule as ships: +- visibility = cross-section × light (`Reflector.cs:46`); +- each sensor adds info in proportion to visibility, sensitivity and angle over distance; +- info decays. + +For ships this is a per-tick integrator. Every sensor visits every entity every tick and keeps a running value +per pair (`Sensor.cs:157-189`). Knowledge rises over a few seconds, then holds or fades. That per-tick loop is +exactly what cannot run over rocks: the probe zone has 809, and the loop costs about 184 ns per body per tick per +sensor, which is the kind of per-rock cost you asked to slim down. + +The gain arithmetic itself is shared in every option below; Cut 3 extracts it into one function. The question is +only how it is integrated over time for a rock. + +*Options.* +- **A. Settled value, on demand.** A rock's info is the value the per-tick rule would settle at, for this observer, + now. It is computed when something asks (targeting, the fire gate), stored nowhere, and costs nothing per tick. + - What you lose: the rise time. A lit rock is known at its settled level the moment it is in range. A rock never + tries to hide, so that delay carries little play. + - Pings: a ping's boost is `visibility × …` (`Sensor.cs:170-175`), so a dark rock gains nothing from a ping in + any option. + - A parity test pins that a ship with the same reflector, held still, settles at exactly A's value. +- **B. Per-tick accumulation within reach.** Each sensor-bearing ship integrates rocks within its longest weapon + range every tick. + - It has the ships' exact dynamics, rise time included. + - Cost per tick grows with ships × rocks in reach, and it keeps state per ship per rock, which needs pruning. +- **C. Lazy integration.** Store (value, last-time) per ship per rock, and integrate the elapsed time when queried. + - It is close to B's dynamics with no per-tick cost. + - The state grows with every rock a ship ever looked at, and it is approximate when light changes between + queries. + +*Recommend A.* It is the same rule with no new state and no per-tick cost, and it matches the campaign's founding +invariant: chunk quantities are pure functions of time. The one behaviour it drops, the rise time, belongs to things +that move and hide, and rocks do neither. + +**Q16. How does a belt get its field kind, including belts in runs that started before this campaign?** (Blocks +Cut 3.) + +*Context.* +- Your Q11 ruling makes field kinds authored records. A chunk's reflectivity (Cut 3) and its yield table (Cut 5) + both come from its belt's kind. +- Today belts are generated with no kind (`AsteroidBeltData`, `ZoneData.cs:101-105` @`283ce7dc`), and a saved run + keeps its belts for the whole run. +- Only one kind will exist at first ("Asteroid"), but you will add more. + +*Options.* +- **A. Derived, never stored.** A belt's kind is a pure function of the belt's identity and the catalog's kinds + (weighted pick). Nothing is saved. Adding or reweighting a kind silently re-kinds belts the player has already + mined. +- **B. Stored at generation, assigned once for old belts.** New belts get a kind when generated. A belt loaded + without one is assigned by the same function the first time it loads, and saved. Adding a kind later never + changes an existing belt. +- **C. Stored at generation only.** Belts from older runs stay kind-less: dark, unminable and unseen, until New + Game. + +*Recommend B.* One assignment function serves both paths (generation and first load), so there is no second +rule. A belt keeps the identity the player learned. A is the only option where a catalog edit rewrites the world +under a running save. + +## A.6 Dependencies (mining) + +- **Fire-control-12 on master:** hard, for every cut from M0 on. +- **Both CultLib roots in every build:** CultCache `45c2f40` and CultMath `6d5e209` (shared precondition). The + `VerifyCultLibRevision` guard does not fire on the Linux verify host. That is a separate defect, handed to Self. +- **The no-target fuse ruling:** the same lines as Cut 4 (`FireControl.cs:389`). Land it first or fold it in. +- **F12-2, the penetration retune:** Q4's `MiningDepthPerPenetration` absorbs it. It gates Cut 7 §4's optional + Ionizing penetration step. +- **F12-6, mines:** when mines ship, they are sourceless blasts. They wear chunks and yield nothing (Cut 4's null + sink). +- **Settings-globals:** Cut 5's tunables join `GameplaySettings` and `PlanetSettings` wherever those live. None may + be CultMath-typed while settings are Unity-serialized. +- **No longer dependencies (Cut 6 deleted):** locomotion Cut 4, and F12-5 (AI and proximity blasts). +- **Document variants:** + - **C2 (element ids, in flight on `hands/variants-c2a`):** + - "Ids everywhere" (variants Q3) requires an element-id member on every object-list element type. For mining, + that is `FieldYield` (new, Cut 5), the affinity pair type inside it (new), and `ChunkWearPack` + (`ZoneData.cs:51` @`283ce7dc`, a list element in `ZonePack`). + - If C4 lands first, Cuts 3 and 5 add ids to their own new types. If they land first, C4's sweep lists them. + - **Slot coordination:** Cut 5 claims key 11 on `ItemInstance` (the `Lot` move), and Cut 3 claims the next + free key on `AsteroidBeltData`. C4 must not put an element id on either. Whichever lands second checks. + - **C3 and C4 (Studio, then Aetheria adoption at `caching-unity-v1.5.0`):** + - Field-kind families ("rich belt" as a variant of "Asteroid") and the two new weapons (variants of ClearPath + and Spectra, with a nested `DamageType` override) are the natural variant content. + - If Cut 7 lands before C4, it authors them as plain records and adds them to C4's re-author list. + - Aetheria pins CultLib `45c2f40` and `caching-unity-v1.4.0` today (`Directory.Build.props`). No mining cut + needs a CultLib bump. + - **For the C4 owner, not mining:** four catalog records hold a null `Behaviors` element: Refinery, Deep Ore + Extractor, Assembly Line and Shipyard (section 0, present on `b66ba524`). C4's `MintElementIds` pass over + `Aetheria.cc` has to decide what a null list element is: refuse it, drop it, or skip it. The variants cut map + does not say. diff --git a/docs/mining-cut.md b/docs/mining-cut.md new file mode 100644 index 000000000..d9f601a28 --- /dev/null +++ b/docs/mining-cut.md @@ -0,0 +1,1081 @@ +# Mining: Target and Cut Map + +Date: 2026-09-25 (corrected for M0, 2026-09-30) + +Status: target and cut map, from an Imagination pass. Cuts 1 and 2 have landed on `codex/mining` (see the status +header in Part III), which was merged with master at M0 to carry fire control. The first half is the +**target** (the ends). The second half is the **cut map** (the means). When the campaign closes, split the target +out as `docs/mining-target.md` and keep this file as history. + +Anchors are against `codex/fire-control-12` HEAD `8bd25f6f`, read from a clean worktree of that commit. **Every +anchor in a fire-control file is stale at `b66ba524`** (`FireControl.cs` is 1,614 lines there; `Splash` is gone); Cuts 3 +and 4 below carry the re-anchored versions, and the `8bd25f6f` anchors elsewhere are history. Claims +marked **(probe)** were measured by running code; claims marked **(read)** come from source at the cited anchor. +Probes live in the session scratchpad and are not kept in the repo: + +- `miningprobe`: a console build against `Aetheria.Shared` and `tools/AetherDb` (CultLib `45c2f40`, CultMath + `v0.2.4`). It opens the catalog and a copy of the operator's real save (`GameData/run.cc`, 2026-09-22), reads + `PlanetSettings` and `GameplaySettings` from `Assets/Resources/Settings.asset` through `AuthoredSettings`, builds + the densest saved zone as a real `Zone` (entities stripped so the belt cost is isolated), and times + `Zone.Update` over 3000 ticks at dt 1/60. Run ten times. +- `miningcensus`: walks every stored document in `GameData/Aetheria.cc`. +- `sensorbench`: the inner loop of `Sensor.Execute` (`Behaviors/Sensor.cs:163-189`) against a `ReactiveDictionary` + carrying the one `ObserveReplace` subscriber `Entity.Activate` installs (`Entity.cs:237-253`). +- The legacy record: `scratchpad/legacy-0305.json` and `legacy-0414.json` (decoded `AetherDB.msgpack` history and + `Legacy/AetherDB.2021-04-14.msgpack`), and the deleted `GameData/SimpleCommodityData/*.json` at `c6ffcad3^`. + +**Scope: mining only.** Fire control Cut 12.4 closed 2026-09-29 (`c50029d5`; 362 tests at that close) and is on +master (`dcd7bbc5`, 2026-09-30), merged into this branch at M0. Cuts 3 and 4 below edit `FireControl.cs` and +`Entity.cs`, code fire control rewrote after this map was first written, so they are re-anchored to `b66ba524`. Cut 1 +and Cut 2 touched no fire-control code. §Overlap names every shared line. + +--- + +# Part I: Target + +## Operator's words (2026-09-25) + +On the asteroid simulation: + +> "Calculating the positions and sampling all those gravity wells for every asteroid was literally the heaviest +> operation in the game, which is why it got explicit multi threading" +> +> "Would be nice if we could slim that down" + +The campaign: + +> "Make that the mining campaign, then. You target the chunk, hit it with a weapon that does the right kind of +> damage for the ore you want to extract, and each chunk rolls its loot table with a weight determined by some +> arcane combination of the damage type, the chunk's composition, and the stats of the weapon you hit it with." + +## Standing rulings this campaign obeys (not re-litigated) + +- **Same inputs.** "Player and AI are controlling the same ship with the same inputs" (`docs/locomotion-cut.md` + R2, branch `codex/locomotion`). A chunk becomes a target through the same slot, the same writer and the same + fire path for both. +- **Reciprocal formulas.** "When the formula is unintuitive, we don't make the handle more opaque for correctness, + we fix the formula. Plenty of core gameplay formulas are reciprocal just for this reason." Every weapon stat in + the loot weight reads as a benefit. +- **WeaponModifiers are labels, not behaviour** (fire-control Cut 12 ruling). The loot roll reads weapon data + fields (`WeaponData.DamageType`, `Damage`, `Penetration`), never `WeaponModifiers`. +- **Provenance** (`docs/item-provenance-target.md`): mined ore enters the economy as a lot in the one + `ProvenanceLedger`. Aetheria grows no second provenance owner. Branding is derived, never stored. +- **Content audits check pre-breach history.** Absence from `GameData/Aetheria.cc` is not absence. Recoverable + content is an operator review item (Cut 7). +- **A shot is decided at Commit and performed at arrival** (fire-control R4). The shot's dice are its own + (Cut 6b, 9.1: `MixSeed(CombatSeed, ShotId)`); nothing reads a shared stream. +- Delete before adding. A gap goes to its owner. A new persisted field on an existing document is nullable, or + has a default that the MessagePack nil rule already produces. + +## Objective + +A player or an AI targets an asteroid chunk, fires the weapon whose damage type suits the ore it wants, and each +hit yields ore into its cargo, weighted by the chunk's composition, the damage type and the weapon's stats. Every +unit is born as a lot with honest provenance: extracted, here, from this belt. The belt simulation behind it costs +nothing per tick. + +## Invariants + +1. **A chunk's pose is a pure function of time.** Position and rotation are evaluated when something asks: the + renderer, targeting, range, hit resolution. Nothing stores a pose. There is one orbit function, and if a GPU copy + is ever added, a parity test pins it to that function. +2. **Zone.Update does no per-asteroid work** and starts no threads. +3. **One target slot.** A ship has one current target, which is either a ship or a chunk, set by one writer path + that both the player and the AI call. The fire path reads the slot, not a parallel channel. +4. **One fire path.** A shot at a chunk is a `PendingShot` that goes through `FireControl.Fire`, `Step`, `Commit` + and `Apply`, priced from the same factor functions a shot at a ship uses: Accuracy, PSensor, Sigma, PSpread and + the exact Gaussian mass over a silhouette. A chunk's silhouette is its disc. +5. **The loot is decided at Commit.** The yield and the ore picks come from the shot's own generator, in a fixed + draw order after the hit draw. Apply only performs them. +6. **Every mined unit carries a lot.** The lot is `Extracted` from this zone and this belt, and stacks merge only + within one lot. A mined unit never exists without a ledger entry. +7. **A chunk never yields more than its authored total**, whatever weapon breaks it. +8. **Composition is derived, never stored** (recommended in Q7): a pure function of the belt's identity and the + catalog's ore table. + +## Canonical implementations + +- Orbit evaluation: `OrbitData.Evaluate` (`ZoneData.cs`) and `Zone.GetOrbitPosition` (`Zone.cs:182-217`). Chunk + pose gets one owner beside them. +- The hit model: `FireControl` (`FireControl.cs`), Cut 12 shape. +- Cargo: `EquippedCargoBay.TryStore(SimpleCommodity)` (`Entity.cs:1808-1861`). Pickup already uses + `Entity.CargoBays.Any(c => c.TryStore(item))` (`Gameplay/ShieldManager.cs:44`). +- Provenance: `ProvenanceLedger`, `Lot`, `Extracted` (`Provenance.cs`); mints through `ItemManager`. +- Deterministic dice: `FireControl.MixSeed` (`FireControl.cs:461`) and `Zone.CombatSeed` (`Zone.cs:60`). + +## Not consumers + +- `AetheriaEve` and the old daemon rebuild: taxidermy. +- Njordr: shape precedent only (`Extracted { source, at }`, `njordr-engine/src/lib.rs:98`), adopted as local typed + state, as the provenance campaign already did. +- `Agents/Tasks/Survey.cs`, `HaulingTask.cs`, `StationTowing.cs`: data-only task classes with no reader. They are + not touched. +- The commented-out 2020 `MiningController` (`399b933a`, deleted in `c6ffcad3`): design intent only. It drove + `MiningTool` through a `Switch`. The operator's model replaces that tool. + +## Out of scope + +- Backward economy generation, routes and piracy (next scope, `docs/three-gates-scope.md:141-145`). This campaign + writes the first real `Extracted` lots, and generation will later synthesize more of the same shape. +- Refining, crafting from ore, prices for ore (every live simple commodity has `Price 0` except Ammo, priced 1000 **(merged-tree census)**). +- AI cargo offload at a home station (the 2020 controller's `GoHome`). An AI miner with full cargo stops mining. +- Electronic-warfare clutter and masking beyond reflected light ("a ship ... can float in a debris field or asteroid belt + and pass as junk", `docs/three-gates-scope.md:155-157`) stays parked there, and so does black-body emission for + chunks. Detection of a chunk by reflected light is **in** this campaign (Q2 correction, 2026-09-30; Cut 3). +- Orbits beyond belts. `Zone.Update` still steps the planet orbits (`:156-161`), which cost about 31 us/tick in the + probe zone. That is cheap and out of scope. + +--- + +## 0b. Identity, lifecycle, authority + +| Kind | What names it | What happens to it over time | Who decides | +|---|---|---|---| +| **Chunk** | `ChunkId = (CultRecordKey Field, int Index)`. `Field` is the field's record key in the run store (today always an `AsteroidBeltData`; debris fields are another kind, Q2); `Index` indexes its chunks (`Asteroids` for a belt). Stable across save/continue, because `RunSave.Commit` writes the run store's whole view, bodies included (`SavedGame.cs:97-99`), and nothing rewrites `Asteroids` after generation. | Born at zone generation (`ZoneGenerator.cs:141-153`). Lives for the run. Removed with every `BodyData` by `RunSave.Clear` (`SavedGame.cs:124`). | `ZoneGenerator` writes `Asteroids` once. Nothing else may write it. | +| **Chunk pose** (position, rotation, velocity) | Derived: `(ChunkId, zone time)`. | Never stored. Evaluated on demand. | One function on `AsteroidBelt` (Cut 1). Forbidden writers: any stored pose array, any per-tick loop. | +| **Chunk wear** (damage taken, broken-until time) | Keyed by `ChunkId` inside the owning `Zone`. | Damage accumulates at Apply. When it reaches the chunk's hitpoints, the chunk breaks with `RespawnAt = zone time + AsteroidRespawnTime(size)`. At `time >= RespawnAt` it is whole again, read lazily with no timer. Persisted in `ZonePack` (new nullable key 6) with the zone's own `Time`, and pruned of expired entries at pack. | `FireControl.Apply` through one `Zone` method is the only writer. `Zone` owns the storage. | +| **Chunk composition** | Derived: `(belt identity, ChunkId, catalog ore table)`. | Never stored (recommended, Q7). A retune of the catalog retunes every belt. | The catalog author, through per-ore data on `SimpleCommodityData`. The derivation function is the only reader of that data for belts. | +| **Ore affinity and abundance** | Per ore: new nullable fields on `SimpleCommodityData` (keys 12 and 13 are free. Only key 11 is retired on `SimpleCommodityData`; keys 6, 7 and 8 are live, inherited from `ItemData` (`SpecificHeat`, `Conductivity`, `Price`, `ItemData.cs:291-298` @`b66ba524`). The comment at `ItemData.cs:304` wrongly lists 6, 7, 8 and 11). | Authored in Studio. Changes with the catalog. | Catalog author. | +| **Global loot tunables** | `GameplaySettings` / `PlanetSettings` fields (Cut 5). | Authored. Today they live in `Settings.asset`. They move with the settings-globals campaign (`docs/settings-globals-cut.md`, mapped, not landed). | Catalog author. | +| **Mined lot** | `LotId` in the run's `ProvenanceLedger`. `Lot { Design = the commodity, Origin = Extracted { Zone, Commodity, Body }, Quality, Roles = null }`. | Minted at the first extraction from a (zone index, belt, commodity) source (Q6). Reused for later units from that source. Collected by `Reachable` when no instance reaches it. A later extraction after collection mints a fresh lot. | `ItemManager.ExtractedLot(zone, belt, commodity)` is the only writer. Its source→lot index is derived at load, not persisted. | +| **Mined unit** | A `SimpleCommodity` instance, `Data` = commodity, `Lot` = the source lot (`Lot` moves to the `ItemInstance` base, key 11). | Stored in cargo. Stacks merge only within the same `Data` and `Lot`. A split copies `Lot`. | `EquippedCargoBay` owns placement. The mint owns `Lot`. | +| **Current target** | `Entity.Target` (runtime; not packed, `EntitySerializer.cs` has no target field **(read)**). | Set by targeting inputs or AI state. Cleared by the existing entity-removal paths. A broken chunk stays held and prices 0 until retargeted (Cut 3). | Player: the `ActionGameManager` targeting handlers. AI: the Minion states. Both write the one slot. | +| **Mining shot** | `ShotId` (`Zone.NextShotId`), as every shot. | Fire → Commit → arrival. Never serialized (`FireControl.cs:852-855`). | `FireControl`. | + +No cell is empty. The cells marked "recommended" depend on the rulings below and are rewritten if a ruling goes the +other way. + +--- + +## Operator questions, in the order they block cuts + +The 2026-09-30 rulings on Q4-Q13 are recorded in the Part III status header. Where a question below was written before its ruling, the ruling wins and the recommendation text is history. + +Self delivers these one at a time (memory: pace operator questions). Cut 1 needs no ruling. The CPU-versus-GPU +belt renderer is a stated default: CPU, one orbit function, so no parity test is needed. Reopen it on request. + +**Q1 (blocks Cut 2). What becomes of `MiningTool`, `ResourceScanner` and the Drill Bit?** **Ruled 2026-09-25: A.** +Operator: "Bring back the drill, indeed." +The operator's model is "hit it with a weapon". `MiningTool` (`Behaviors/MiningTool.cs`, 76 lines) is a separate +damage channel with its own DPS, efficiency, penetration and range. Nothing assigns its target (`AsteroidBelt` and +`Asteroid` have no writer), and it throws `KeyNotFoundException` if it ever executes with an unset belt +(`:59` indexes before the `IsSet` check at `:60`). No live item carries it. The legacy catalog did: **Drill Bit** +(GearData; `MiningToolData` DPS 1-2.5, efficiency 2-5, penetration 1-5, range 5-20) and **Resource Scanner** +(`ResourceScannerData` range 100-200, minimum density 25-5, scan duration 5-2) are in both `legacy-0305` and +`legacy-0414` **(probe)**. +- A: Delete `MiningTool` and `MineAsteroid`. Retire union tag 26 (comment, do not reuse). Restore the Drill Bit in + Cut 7 as a short-range weapon, a `ConstantWeaponData` beam at Kinetic, penetration high and range 5-20, so + content keeps its meaning through the one fire path. +- B: Keep `MiningTool` as a second extraction channel beside weapons. +- **Recommended: A.** B is a second hit path with its own dice and its own yield rule, which is exactly the + split Cut 3 of fire control removed. `ResourceScanner` stays parked (Q2 correction, 2026-09-30). + +**Q2 (blocks Cut 3). How does a chunk become a target?** **Ruled 2026-09-25: A, one slot, two kinds.** +Operator: "Note that I have been saying chunks specifically because this should generalize to more than just +asteroid fields ("rocks"), there's various kinds of debris fields we'd want to represent, too. Maybe that's +pedantic, it doesn't change the schema. One target slot, two kinds." So a target is an `Entity` or a **chunk of a +field**; asteroid belts are one kind of field and debris fields another. `ChunkId` is `(Field, Index)` (landed in +Cut 2); new chunk-surface code speaks of chunks and fields, never belts or rocks. Composition and loot tables +(Q4, Q7) are per field kind: a debris field rolls salvage through the same weighted roll. + +**Detection (operator correction, 2026-09-30, replacing the 2026-09-25 "deferred to the electronic-warfare campaign" text).** Operator: "We can just assign a reflectivity and cross section to each chunk just like we do with entities, then their visibility depends on how much light is shining on them, no new mechanism needed ... This is what I actually intended with my ruling about deferring detection." + +The options as mapped, kept for the record: The options below were +costed against the live code: + +- **A. One slot, two kinds.** `Entity.Target` becomes `ReactiveProperty`, where `TargetRef` is a small + readonly value that is either an `Entity` or a `ChunkId`, with value equality. Readers that need a ship read + `Target.Value.Entity` (null for a chunk). FireControl's factor functions take a `TargetRef`, with the chunk + branch at each target-shaped read. Blast radius **(read)**: the count has grown since the first read. At `b66ba524`: 28 `Target` reads in `ActionGameManager.cs`, 22 in + `FireControl.cs`, 11 in `Entity.cs`, plus Unity weapon-manager sites (`GuidedProjectileManager`, `LightningGunManager`, + `ConstantParticleWeaponManager`, `GuidedProjectile`) the first count omitted. The first read was about 60 in 12 files + (`ActionGameManager.cs` 20, `FireControl.cs` 11, `Entity.cs` 8, `TurretController.cs` 7, `LockWeapon.cs` 5, + `EntityInstance.cs` 3, `Weapon.cs` 2, `Combat.cs`, `Minion.cs`, `Ship.cs:84`, `ActionBarSlot.cs`), plus + `PendingShot.Target` and `ShotOutcome.Target`. Most sites are mechanical (`.Entity`). +- **B. A parallel chunk slot.** `Entity.TargetChunk` (a `ReactiveProperty`) sits beside `Target`, + behind one writer that clears the other. About 15 sites. It represents one concept in two fields, and every + reader must know to check both. +- **C. Chunks become Entities.** Rejected on cost **(probe)**. One sensor-bearing entity pays **184 ns per + observed entity per tick**, which is **149 us/tick for the 809 chunks** of the probe zone. With ten ships in a + zone that is about 1.5 ms/tick, the same order as the per-asteroid gravity sampling the operator just asked to + remove (1.9 ms/tick, below). It would also add 809 entries and subscriptions to every entity's + `EntityInfoGathered`, a hull and schematic per rock, and `Entity.Update` per rock. +- **D. Promote a chunk to an Entity while targeted.** Rejected: its identity flips kind mid-shot, and a pending + shot would outlive its target's kind. + +**Recommended: A**, sequenced after fire-control Cut 12 closes. "A ship has one target, and a target is a ship or a +rock" is the one-owner sentence, and it matches the parked electronic-warfare direction: "every body presents a +signature through the shared detection interface". Consequences that come with A, named here and not decided +later: +- **Visibility.** Superseded twice; the live design is Cut 3 (a chunk is detected by reflected light through the + shared sensor gain rule). The scanner-as-chunk-sensor plan, the fallback range tunable, unconditional visibility and + PSensor 1 are all struck. `ResourceScanner` stays parked. +- **Stance.** `StanceAllowsFire` (`Weapon.cs:101`) allows fire on a chunk. +- **Lock weapons.** Launchers cannot lock a chunk. `LockWeapon.cs:86` requires `IsHostileTo`, and rocks are not + hostile, so guided and lock launchers (GT 3K, LRMM72, SRMM72, pswarm, scorched void policy) cannot mine. Rule it + if that is wrong. +- **Blasts (fire-control 12.4).** Superseded by Q10 (ruled A, 2026-09-30): a blast covers every chunk it overlaps, and + the shooter gets the loot (Cut 4). The text this replaces was written against `Splash`, which Cut 12.4 replaced with + `Detonate(Zone, float2 worldPlanar, float radius, float damage, DamageType)` (`FireControl.cs:1074` @`b66ba524`); that + function takes no shooter and walks `zone.Entities` only (`:1084`), so a blast beside a rock did nothing to it. + +**Q3 (blocks Cut 2). Do chunks respawn, and does wear survive save/continue?** **Ruled 2026-09-25: A.** +Today `MineAsteroid` sets `RespawnTimers` (`Zone.cs:316`), but nothing ever decrements them, and `AsteroidExists` +(`:247`) ignores them. A broken asteroid renders at size 0 forever and can still be mined **(read)**. None of this +is persisted. +- A: Respawn after `AsteroidRespawnTime(size)` (authored 10-100 s), as an absolute `RespawnAt`, and persist wear in + `ZonePack`. +- B: Depletion is permanent for the run, persisted. +- C: No persistence. A belt heals when the zone reloads. +- **Recommended: A.** It keeps the authored curve, costs no timer, and makes save/continue honest. C turns a reload + into a belt reset. + +**Q4 (blocks Cut 5). The loot weight formula.** The operator owns this ("some arcane combination"). Proposed: + +For one hit by weapon `w` (damage type `d`, damage `D`, penetration `P`) on chunk `c` (size `s`): + +- **Units:** `n = min(D, remaining HP) / HP(s) × Yield(s)`, taken as its floor plus one Bernoulli draw on the + remainder. `HP` is the existing `AsteroidHitpoints` curve (authored 10-200). `Yield` is a new `PlanetSettings` + `ExponentialLerp`. *Why:* a chunk yields the same total however it is broken (Invariant 7). Damage is a benefit + because it gets you there faster. +- **Weight of ore `o`:** `w(o) = Affinity_o(d) × Comp_c(o)^(1/Depth)`, with `Depth = 1 + P × MiningDepthPerPenetration`. + - `Comp_c(o)`: what is in this rock (Q7). + - `Affinity_o(d)`: the right tool for the ore. It is authored per ore and per damage type, and defaults to 1. + - `Depth`: penetration digs past the common stuff. It flattens the composition toward the rare ores and never + makes the common ore impossible. Precedent: the 2020 and live rule `pow(x.Value, 1f / penetration)` + (`Zone.cs:323`). It is written as `1 + P·k` so that penetration 0, which covers 15 of 18 live weapons, reads as + the raw composition rather than dividing by zero. +- Each unit's ore is drawn from the normalized weights, in a fixed order after the hit draw. + +Worked examples. Composition is Rock .60, Water .20, Gold .15, Uranium .05. Illustrative affinities: Rock Kinetic +1.5, Water Thermal 2, Gold Electric 2, Uranium Optical 2, and 0.5 elsewhere. `MiningDepthPerPenetration = 4`. +Computed, not estimated: + +| Weapon (live stats) | d | P | Rock | Water | Gold | Uranium | +|---|---|---|---|---|---|---| +| Kinetic, pen 0 (Earp) | Kinetic | 0 | .818 | .091 | .068 | .023 | +| Autocannon | Kinetic | .25 | .687 | .132 | .115 | .066 | +| pretty pretty bang bang | Thermal | 0 | .375 | .500 | .094 | .031 | +| ChargeBlast+- | Electric | 0 | .414 | .138 | .414 | .034 | +| ColdFire | Optical | 0 | .522 | .174 | .130 | .174 | +| Optical at pen .25 (none exists) | Optical | .25 | .309 | .179 | .155 | .357 | + +So the right damage type roughly triples an ore's share, and penetration multiplies a rare ore's share further. +Alternatives to put to the operator: +- A: the formula above (Damage sets quantity; damage type and penetration set weights). +- B: A, plus `DamageSpread` as breadth: a yield multiplier `1 + DamageSpread × k`, which strips more rock per hit at + the raw composition. It is a benefit, and GT 3K, plight, pswarm, SRMM72 and scorched void policy author it **(probe)**. +- C: A, with no penetration term (damage type and composition only). This is the simplest to read. +- **Recommended: A.** Every term is one sentence, and every weapon stat in it is a benefit. B is a clean add later, + because it changes no weight. Note: penetration is scheduled for a retune (fire-control F12-2, "Retuning + penetration is a follow up"). `MiningDepthPerPenetration` is the one knob that absorbs that retune. + +Tunables: `Affinity` and `Abundance` per ore on `SimpleCommodityData` (catalog, Studio). `MiningDepthPerPenetration` +on `GameplaySettings`. `AsteroidYield` and `ChunkCompositionVariance` on `PlanetSettings`. Hazard: none of them may be +CultMath-typed while settings are Unity-serialized (memory: Unity-serialized CultMath fields load as zero). + +**Q5 (blocks Cut 5). Is the roll per hit, or once when the chunk breaks?** +- A: Per hit, with units ∝ damage (as in Q4). Each hit's own weapon decides its ore, so switching guns mid-rock + matters, and the only state kept is HP. +- B: Once at break, weighted by the weapon that broke it. +- C: Once at break, with hits accumulating per-ore weight as they land. This needs per-chunk, per-ore state. +- **Recommended: A.** The operator's sentence ties each weight to "the weapon you hit it with". A gives continuous + feedback and keeps no memory. B lets a chip shot from the wrong gun at the end decide the whole rock. + +**Q6 (blocks Cut 5). Where does the ore go, and how big is a lot?** +- Destination. A: straight into the shooter's cargo at arrival, where a full hold loses the overflow. B: a floating + pickup. **Recommended: A.** Pickups are Unity-only (`ItemPickup` and collection by `ShieldManager.cs:40-55`), so + an AI could never collect them, which would break the same-inputs rule. +- Lot granularity. A: one lot per (zone, belt, commodity) source. B: one lot per hit. **Recommended: A.** B makes + every hit a separate cargo stack, because stacks merge only within one lot, and it grows the ledger per shot. A is + what backward generation will synthesize anyway: an extraction source at a place. +- Lot quality: 1 for extracted lots. Nothing reads a `SimpleCommodity`'s quality (price and tier read + `CraftedItemInstance` only, `ItemManager.cs:114,224`). Grade can arrive as its own ruling later. + +**Q7 (blocks Cut 5). Where does composition live?** **Resolved 2026-09-30: A′** (a per-kind entry list of commodity, abundance and affinity, derived, never stored per chunk), with Q11 (field kinds are authored catalog records yielding simple commodities). The per-ore `Abundance` on `SimpleCommodityData` recommended below is superseded: it gives one table for every field kind, which the Q2 ruling (tables are per field kind) contradicts. +`BodyData.Resources` (key 4, `ZoneData.cs:53-54`) is on every body and has never had a writer since the 2020 +generator (`17673cd2`) was commented out. Its only reader is `MineAsteroid` (`Zone.cs:322-323`). No live belt has +resources **(probe: 8 belts, 0 authored)**. +- A: Composition is derived: `Comp_c(o) = Abundance_o × exp(Variance × g(hash(belt key, index, o)))`, normalized, + with `g ∈ [-1, 1]`. Nothing is stored. Existing saves work, and key 4 is retired. +- B: `ZoneGenerator` writes `BodyData.Resources` per belt at generation, and the chunk jitter is derived. Belts in + runs started before Cut 5 have empty compositions until New Game. +- C: Per-belt only, with no per-chunk variation. +- **Recommended: A.** It has one owner (the catalog) and nothing to migrate. It follows the same "derived, never + stored" spirit as branding. The operator's wording ("the chunk's composition") argues against C. If B is chosen, + its generator must seed from the belt's identity, not the shared generation stream (`ZoneGenerator.cs:48`), + because a new draw there would shift every later placement in newly generated zones. + +**Q8 (blocks Cut 7). The catalog has no Corrosive or Ionizing weapon, now or in its history.** Live weapons use +Kinetic (9), Optical (3), Electric (3) and Thermal (3) **(probe)**. `legacy-0305` and `legacy-0414` use the same +four types **(probe)**. An ore whose affinity peaks at Corrosive or Ionizing is unreachable. +- A: Author affinities over the four types that exist, and add Corrosive and Ionizing weapons when content wants + them. +- B: Author new Corrosive and Ionizing weapons in Cut 7. +- **Recommended: A**, with the gap recorded in Cut 7's review sheet. + +**Q9 (blocks Cut 6). Which AI ships mine?** **Ruled 2026-09-30: C** (defer; against the recommendation below). Cut 6 is deleted. Nothing assigns a `Mining` task today. `Zone.CreateAgent` +(`Zone.cs:122-129`) gives every NPC a `PatrolOrbitsTask`. +- A: In a zone with belts, an NPC whose loadout has a weapon with nonzero affinity to some belt ore gets a `Mining` + task on the nearest belt, with an authored share (`GameplaySettings.AgentMinerShare`). +- B: Only dedicated miner hulls or factions mine. This needs faction-territory roles (`docs/faction-territory-target.md:36-49`, + "extraction"). +- C: Defer AI mining. The same path is proven by a test agent only. +- **Recommended: A** for this campaign. B is the faction-territory campaign's call and can replace A's + eligibility rule without touching the path. + +--- + +# Part II: Body map + +## The belt simulation today + +**(read)** `Zone` owns belts (`Zone.cs:26`, built at `:91-92`). Each tick, `Zone.Update` (`:149-179`): +1. advances `_time` (`:151`) and clears `_updatedOrbits` (`:152`); +2. waits on last tick's belt tasks (`:153-155`); +3. steps every orbit (`:156-161`); +4. for each belt, copies `NewTransforms` into `Transforms`, copies `NewOrbitPosition` into `OrbitPosition`, and + starts one `Task.Run(UpdateAsteroidTransforms)` (`:163-168`). + +`UpdateAsteroidTransforms` (`:249-275`) evaluates every asteroid into a `float4` (x, y, rotation, damage-scaled +size). The per-asteroid gravity sample is commented out (`:272`). The shader applies height instead: `Asteroid.shader:28-32` +samples `_NebulaSurfaceHeight` per vertex and adds `_AsteroidVerticalOffset`, and the renderer sets that offset +globally (`ZoneRenderer.cs:442`). + +**Findings (probe), on the densest zone of the operator's save** (EAC-2265, radius 5418, 8 belts of 123, 29, 101, 132, +269, 99, 30 and 26 asteroids, 809 in total, 56 bodies; that save has generated 1 of its 64 zones): + +- **Cost before:** shipped `Zone.Update` with entities stripped costs **400-1760 us wall per tick** (the main + thread blocks in `t.Wait()` at `:153-154`) and **125-420 us process CPU per tick**, over 5 clean runs. +- **Cost after** (the same zone with no belt work): **31-39 us wall, 26-37 us CPU per tick.** Belts become free + per tick. +- The belt arithmetic alone, run sequentially, is 76-94 us/tick for all 809 asteroids. The threading bought no + speed: task scheduling and the blocking wait cost more than the work. +- **One asteroid on demand costs 186-224 ns** (`OrbitPeriod.Evaluate`, `OrbitData.Evaluate` and the parent orbit + lookup). A targeting query over the largest belt (269 asteroids) is about 55 us, paid when asked and not per tick. +- **The threading is a data race.** **4 of 10 probe runs died** with `InvalidOperationException: Operations that + change non-concurrent collections must have exclusive access`, thrown from `Zone.GetOrbitPosition` + (`:209`/`:216`) inside a belt task. Parallel belt tasks, and the main thread's `_updatedOrbits.Clear()` at `:152` + (which runs before the wait at `:153`), mutate the same `HashSet` and `Dictionary` unsynchronized. .NET detects + it; Unity's Mono may corrupt silently instead. Deleting the threading is a correctness fix as well as a cost fix. +- **Every pose the sim or renderer reads is exactly one tick stale.** After `Update`, `Transforms` matches the pose + at the previous tick's time with 0.0000 error, and misses the current pose by 0.209 units at the measured maximum + speed of 12.6 u/s. That comes from the double buffer (`:165`). +- **The operator's history, confirmed.** `GetHeight` for every asteroid, the commented-out line `:272`, costs + **1.9-2.1 ms/tick** over 56 bodies. That really was the heaviest per-tick operation. It is already gone from the + sim, and the shader now does the height. + +## Consumers of the belt state + +| Surface | Readers (HEAD) | Fate | +|---|---|---| +| `AsteroidBelt.Transforms` / `NewTransforms` (`Zone.cs:505-506`) | `Zone.cs:165,230,273`; `MiningTool.cs:62`; `ResourceScanner.cs:80`; `ZoneRenderer.cs:456,462,467,476`; `AsteroidBeltUI.Update` (`ZoneRenderer.cs:660-676`) | Deleted (Cut 1). The renderer fills its own buffers from the one function. | +| `AsteroidBelt.OrbitPosition` / `NewOrbitPosition` (`:508-509`) | `Zone.cs:166,256,271`; `ZoneRenderer.cs:449,451,662` | Deleted. The belt centre is `Zone.GetOrbitPosition(parent)`. `AsteroidBeltUI` already stores `_orbitParent` (`:593,613`) and never reads it. | +| `BeltUpdates` (`:46`) | `:153-155,167` | Deleted. | +| `NearestAsteroid` (`:226-245`) | none. It reads `Transforms[i].xz` (x and rotation) where it means `.xy` | Deleted. Cut 3 adds the one chunk query. | +| `AsteroidExists` (`:247`) | `MiningTool.cs:61` | Replaced by `Zone.ChunkExists` in Cut 2. | +| `RespawnTimers`, `Damage`, `MiningAccumulator` (`:510-512`) | `Zone.cs:260-264,306-318` | Replaced by the wear store (Cut 2). | +| `Zone._random` (`:33,71`) | `MineAsteroid` only (`:323-324`) | Deleted with `MineAsteroid`, Q1=A. It is a shared stream, which Cut 6b forbids for combat dice. | +| `ActionGameManager` save/continue | no belt reader. `RunSave.Capture` packs entities, orbits and planet refs only | Gains wear through `Zone.PackZone` (Cut 2). | +| `SectorRenderer.cs:84` (belt count), `MapRenderer.cs:55-73` and `MainMenu.cs:257-259` (`ShowAsteroidUI` toggles) | none read poses | Untouched. | + +## The mining path today, end to end + +Unreachable **(read)**: `MiningTool.Execute` (`MiningTool.cs:56-75`) range-checks against the stale `Transforms` and +calls `Zone.MineAsteroid` (`Zone.cs:297-335`). That accumulates damage and a per-miner accumulator, picks a resource +from the always-empty `beltData.Resources`, and mints nothing: the `SimpleCommodity` mint is commented out +(`:327-333`, `// TODO: Drop item onto the Grid`). No code assigns `MiningTool.AsteroidBelt` or `Asteroid`. The AI +`MiningController` that did (`399b933a`) was deleted in `c6ffcad3`. `Agents/Tasks/Mining.cs` has no reader, and +`AgentTask` is "owned by their Agent and not persisted" (`AgentTask.cs:10`). + +## How FireControl sees a target (what a chunk must answer) + +**History, anchored at `8bd25f6f`.** Every line number below is stale (`FireControl.cs` is 1,614 lines at `b66ba524`, `Splash` is gone, and the chunk visibility and info rows are superseded by Cut 3). The re-anchored, live version is in Cuts 3 and 4. + +**(read, `FireControl.cs` at `8bd25f6f`)**: + +| Read | Where | For a chunk | +|---|---|---| +| `source.Target.Value` | `Fire` `:332` | the slot (Q2) | +| `target.Position`, `range` | `PFire` `:170-171`, `Inspect` `:246-247` | `Zone.ChunkPosition` | +| `source.VisibleEntities.Contains` | `PFire` `:173`, `Inspect` `:248` | within chunk detection range (Q2) | +| `EntityInfoGathered[target]` → `PSensor` | `PFire` `:179-180` | info 1 | +| `LockWeapon.IsLocked` | `PFire` `:175` | never locked (Q2) | +| `InArc(toTarget)` | `PFire` `:176` | unchanged | +| `target.Velocity` | `PredictedIntercept` `:135-138`, `Fire` `:349` | `Zone.ChunkVelocity`: analytic orbital velocity plus the parent `Orbit.Velocity` (`Zone.cs:160`) | +| `target.Hull`, `Silhouette(target, hull, aimed, bearing, precision)` | `Forecast` `:196-205`, `CommitProbability` `:486-487`, `Commit` `:531` | a disc of radius `r / SchematicCellSize` cells, one interval `[-R, R)`, `a = 0`, bearing-free | +| `DeviationProbability` (live position) | `:310-316` | unchanged in shape. A chunk's curvature over a 2 s flight is `v²t²/2R` ≈ 0.16 u at the probe belt, well inside any Tracking | +| `zone.Entities.Contains(target)` (gone → miss) | `Step` `:409` | `!Zone.ChunkExists` (broken) → miss | +| `Shield` | `Commit` `:552-555`, `Apply` `:570-575` | none | +| `ApplyHit` | `Apply` `:581` | `Zone.Mine(...)`: wear plus the decided loot | +| `Splash` over `zone.Entities` | `:613-642` (being replaced by 12.4) | Q2: targeted chunk only | + +`Silhouette` (`:679-726`) does three things: builds the intervals, merges them, and integrates the Gaussian. The +integration (`:710-716`) and `Span` (`:718`) are the only parts a disc needs. Cut 4 extracts that tail as the one +integrator, used by the hull path and `Silhouette.Disc` alike, so no second copy of `Phi` summation exists. + +## The catalog and the legacy record (probe) + +- **Live simple commodities (13):** Minerals: Hydrogen, Potassium, Rock. Metals: Gold, New Metals, Uranium. + Compounds: Methane, Water. Organics: Algae, Animal Products, Bacteria, Fungi. Ammo: Ammo. Every one is priced 0 except Ammo (1000). The catalog oid moved after `8bd25f6f` (`4f218e85`, `dd437580`, locomotion Cut 1): `1862ddc4` then, `2aa79c0a` at `b66ba524`. +- **Legacy (30, identical in `legacy-0305` and `legacy-0414`):** the 13 live ones, plus Crystal, Silicon, Carbon, + Nitrogen (Minerals); Iron, Titanium, Copper, Lead (Metals); Ethanol, Hydrocarbons, Acid, Oxygen, Carbon Dioxide + (Compounds); Plant Matter (Organics); and Autocannon, Charged Shotgun, Flak Cannon and Auto Shotgun ammo. That is + 17 missing from live, 14 of them plausible ores. +- **Resource-distribution data does not survive.** The 2020 fields (`ResourceDensity` map layers, `Minimum`, + `Maximum`, `Exponent`, `Floor`; `17673cd2`) were keys 6, 7, 8 and 11 on `SimpleCommodityData`. Only key 11 stays retired there; 6, 7 and 8 are live `ItemData` keys (`SpecificHeat`, `Conductivity`, `Price`). No values remain in the deleted + JSON records at `c6ffcad3^` (for example, `Uranium.json` carries only name, mass and category) or in either + msgpack. +- **Mining gear:** Drill Bit and Resource Scanner exist in legacy only (Q1). Surface Ore Extractor and Deep Ore + Extractor are behaviourless husks in both live and legacy (`docs/item-provenance-substrate.md:62-64`), and Mining + Rights is a compound commodity. None of these are touched here. The extractors are station factory gear for + backward generation. +- **Weapons:** see Q8. The catalog has no `BodyData` (bodies are run records). + +## Provenance: how a mined lot is born + +`Lot` is on `CraftedItemInstance` at key 11 (`ItemInstance.cs:38`). The provenance map put it there on purpose, +"`SimpleCommodity` gets no lot ... Moving it to the base later with the same key is byte-identical (probe Q4), so +mining yield adds without reshaping" (`docs/item-provenance-cut.md:150-156`). `SimpleCommodity` uses key 2 +(`ItemInstance.cs:47`), so key 11 is free on it. + +The mint: +- **Who:** the shooter's shot, at Apply. +- **What:** the commodity (`Lot.Design`). `Extracted.Commodity` repeats `Lot.Design`, and the provenance cut chose + that deliberately (`Provenance.cs:116-120`). It is left alone. +- **Where:** `Extracted.Zone`, which is the run's zone index (`Array.IndexOf(Galaxy.Zones, GalaxyZone)`, the index + `savedzone-{i}` uses, `SavedGame.cs:103`), plus a new `Extracted.Body` (key 2, + `CultRecordRef`, nil on any older record). That is Njordr's "source". +- **Quality:** 1 (Q6). **Roles:** null. + +Three existing rules must learn about simple lots: +- `RunSave.Commit` collects lot roots from `OfType()` only (`SavedGame.cs:110-114`). Mined lots + would be garbage-collected at the first save unless the roots take every `ItemInstance` with `Lot != 0`. +- The stack merge (`Entity.cs:1838`) matches on `Data` only. +- The stack split (`Entity.cs:1916`) does not copy `Lot`. + +A `SimpleCommodity` with `Lot == 0` is unattributed: the ledger starts at 1 (`Provenance.cs:14`), so 0 never names a +lot. That includes every existing loadout ammo stack. Reading such a lot through the ledger's indexer still throws. + +## Overlap with work in flight + +- **Fire control (main tree, other agents).** Cuts 3 and 4 edit `FireControl.cs` at `Fire`, `Step`, `PFire`, + `Forecast`, `HitProbability`, `Inspect`, `CommitProbability`, `Commit`, `Apply`, `Silhouette`, `PendingShot` and + `ShotOutcome`, and they touch `Splash`'s successor from 12.4. They must follow Cut 12.4. Cuts 1, 2 and 5-7 do not + touch `FireControl.cs`, except Cut 5's loot-draw lines inside `Commit`, which also wait for 12.4. +- **Locomotion (`codex/locomotion`).** Its Cut 4 rewrites `MoveTo` and the heading planner. Cut 6 is deleted (Q9 = C), so + this campaign no longer depends on locomotion Cut 4. +- **Settings globals (`docs/settings-globals-cut.md`).** Cut 5's new tunables join `GameplaySettings` and + `PlanetSettings` wherever those live when it lands. +- The uncommitted `AetheriaInput.cs` change in the main tree ("Cycle Target Item") is fire-control work. Cut 3's + targeting handlers sit beside it in `ActionGameManager.cs:372-404`. + +--- + +# Part III: Cut map + +## Status header + +Status: cut map. Ends are owned by Part I of this document; Part III owns the means. Updated at M0, 2026-09-30. + +**M0 (2026-09-30):** `codex/mining` merged with master (fire control, `dcd7bbc5`) and this map corrected against +`docs/mining-cut-refresh.md` (v2). Cuts 3 and 4 below are the refresh's versions. The refresh's Cut 5 and Cut 7 texts are +the current proposals and are not yet folded into this file; where they differ, the refresh wins. + +Rulings (operator), 2026-09-25: Q1 = A, Q2 = A (one slot, two kinds; field generalization), Q3 = A. + +Rulings (operator), 2026-09-30: + +| Q | Ruling | +|---|---| +| Q4 | A, the formula as written (Cut 5) | +| Q5 | A, per hit (Cut 5) | +| Q6a | A, straight into cargo; overflow lost (Cut 5) | +| Q6b | A, one lot per (zone, field, commodity) source (Cut 5) | +| Q7 | A′, a per-kind entry list (commodity, abundance, affinity), derived, never stored per chunk (Cuts 3 and 5) | +| Q8 | **B**, author Corrosive and Ionizing weapons (Cut 7), against the earlier recommendation | +| Q9 | **C**, AI mining deferred; the path is proven by a test agent in Cut 5. Cut 6 is deleted | +| Q10 | A, a blast covers every chunk it overlaps; the shooter gets the loot (Cuts 4 and 5) | +| Q11 | field kinds are authored catalog records; tables yield simple commodities only (Cuts 3, 5, 7) | +| Q12 | A, launchers cannot mine (Cuts 3 and 4) | +| Q13 | A, reach is the longest active weapon range | +| Q2 correction | Chunks are detected by the existing reflectance rule (reflectivity and cross-section, lit by the suns), not deferred to EW. Struck: the scanner as chunk sensor, the fallback range tunable, unconditional visibility, PSensor 1. `ResourceScanner` stays parked | + +**Q14 A and Q16 B are operator rulings (confirmed 2026-09-30: "Q14. A (thought I ruled on that previously). Q16. +B.").** Cut 3 is written for them and is unblocked. + +**M-Soul content unknown:** no Soul report for Cuts 1-2 exists; the mutation sweep at `22fb4021` stands in. Cut 3 +does not start until M-Soul is closed or empty. + +**Still owed:** +- From Cut 1: a Unity compile of the `ZoneRenderer` and `AsteroidBeltUI` change (M0 runs it), an isolated Stryker rerun, + and the operator's fly-through. Nothing after `d9c887c1` records any of them. +- From Cut 2: Soul has not verified the two-commit fix (`34994732`, `93bfdebd`). It folds into Cut 3's Soul pass. + +Test counts: the map's 274 after Cut 2 were counted on its own `8bd25f6f` base. On the merged tree (`fc90e936`, master plus +mining) the suite is 498 tests, all passing on Yggdrasil (CultLib `45c2f40`, CultMath `6d5e209`). + +**Cut 2: landed 2026-09-25** at `3b9f0f1d`, `c924bae0`, `daa64560`. 272 tests; production net −73 lines; `AetherDb census` byte-identical; break threshold kept strictly `>`; `ZonePack` key 6 nullable, proven by a raw-MessagePack older-record test. Spec discrepancy fixed: the retirement comment for union tag 26 no longer names `MiningToolData`, which the cut's own negative grep forbids. Soul found one real defect, dormant until Cut 4 wires `FireControl.Apply` into `Wear`: a hit on a chunk that was +already broken reset `BrokenUntil` and resurrected it on the spot (any shot resolving after the chunk broke — +travel time, two shots close together). Fixed at `34994732`: a hit on a broken chunk changes nothing and `Wear` +returns false; pinned below and above hitpoints and across a save taken inside the respawn window (both tests fail +on the old code). `93bfdebd` removes the dead `MiningDifficulty: 500` still serialized in `Settings.asset`. Soul also +confirmed the strict `>` threshold cannot stall (wear accumulates across hits; only zero damage never breaks). +274 tests. **Closed**; the two-commit fix's own check folds into Cut 3's Soul pass. + +**Cut 1: landed 2026-09-25** at `22a54ccb` and `d9c887c1` (Stryker fix batch: `EvaluateBelt` had no +coverage and `Radius`'s `Max`→`Min` survived). 262 tests. Probe on the operator's densest zone, 10 runs each: +before, 541-3654 µs wall and 120-391 µs CPU per tick with **3/10 runs crashing** on the belt-task race; after, +95-185 µs wall and 99-152 µs CPU, **0/10 races**. Soul closed it: no thread or task left in ServerShared, poses a +pure function of zone time (formula byte-identical to `e729e6d6`), one owner, no per-frame renderer allocation +(the buffer is allocated once in `LoadPlanet`). + +Correction to this map's own baseline: the "31-39 µs with no belt work" figure above does not reproduce for a +zone of this shape. Soul's independent probe of the same 56-body, 8-belt layout: 100 µs per tick with belts, +114 µs with the belts replaced by plain bodies at equal orbit count — belts cost nothing per tick. The ~100 µs +that remains is the pre-existing per-body orbit step (about 2 µs per orbit over ~57 orbits), untouched by this +cut. The 31-39 µs was measured on a smaller body count. + +Still owed for Cut 1: a Unity compile of the renderer change (`ZoneRenderer.cs`, `AsteroidBeltUI`), which runs +when this branch meets a tree Unity can open; an isolated Stryker rerun by a later Soul (the closing pass's run +collided with its own concurrent build); and the operator's fly-through. + +Follow-ups outside this campaign: +- `AsteroidBeltUI.Update` feeds `Quaternion.Euler(90, transform.z, 0)` a rotation in radians where the API takes + degrees (`ZoneRenderer.cs:666`). It is a presentation defect and is not fixed here. +- The planet orbit step stays per tick (`Zone.cs:156-161`). It is now the whole of the zone's per-tick cost + (~2 µs per orbit). 2 µs for one orbit evaluation is more than its arithmetic warrants — curve evaluation or + the `_updatedOrbits` memo is the likely cost. Measure before touching it; a candidate follow-up, not scope. + +## Cut order + +| Cut | Nature | Depends on | +|---|---|---| +| 1 | Subtraction: belts evaluated on demand. No threads, no stored poses. | none | +| 2 | Subtraction plus one owner: the dead mining path goes, and chunk identity and wear get one owner | Q1, Q3 | +| M0 | Placement: merge master, correct the map | fire-control-12 on master | +| M-Soul | Fix slot for the parallel Soul pass over Cuts 1-2 (content unknown) | Soul report | +| 3 | A chunk is a target, detected by reflected light; the field-kind record appears | M0, M-Soul (Q14 A, Q16 B ruled) | +| 4 | A shot at a chunk goes through FireControl; blasts cover chunks | 3; the fire-control no-target fuse ruling | +| 5 | Composition, loot roll, provenance, deposit | 4; Q4-Q7 | +| ~~6~~ | **Deleted** (Q9 = C); the test agent moved into Cut 5 | none | +| 7 | Content: ores, affinities, abundance, Drill Bit, Corrosive and Ionizing weapons (operator review) | 5; Q1, Q8 | + +Cuts 1 and 2 are kept apart so Soul can falsify "nothing changed" (Cut 1) separately from "the dead path is gone +and wear has one owner" (Cut 2). + +--- + +### Cut 1. Belts are evaluated when asked + +- **Repo/branch:** Aetheria, `codex/mining` from `codex/fire-control-12` `8bd25f6f`. Depends on nothing. +- **First:** re-run `miningprobe` on the branch point and record the before numbers in the commit message (wall and + CPU per tick, and the race count over 10 runs). +- **Deletes first** (`Zone.cs` at `8bd25f6f`): + - `:9-10` `using System.Threading; using System.Threading.Tasks;` (2 lines); + - `:46` `BeltUpdates` (1); + - `:153-155`, the wait loop (3); + - `:163-168`, the per-belt copy and `Task.Run` (6); + - `:226-245` `NearestAsteroid` (20); + - `:249-275` `UpdateAsteroidTransforms` (27); + - `:505-506` and `:508-509`, the four `AsteroidBelt` pose fields, and `:517-518`, their allocations (6). + - About 65 lines in total. +- **Keeps:** `AsteroidBelt.Data`, `Radius` (`ZoneRenderer.cs:452`), and the wear dictionaries (Cut 2 replaces them). + The per-orbit step `:156-161`. +- **Adds:** on `AsteroidBelt`, one function that is the only place a chunk pose is computed: + `Pose(int index, double time, float2 parentPosition, PlanetSettings)` → (position, rotation). It is the formula of + `:269-271`, verbatim, with `time` in double exactly as `_time` is used now. There is also a size read, + `Size(int index, PlanetSettings)`, which is the rule of `:259-267` moved unchanged, and a batch + `Evaluate(Span into, double time, float2 parent, PlanetSettings)` that loops `Pose` and `Size` for the + renderer. `Zone` exposes them at its own time: `ChunkPose(CultRecordKey belt, int i)` and + `EvaluateBelt(CultRecordKey belt, Span into)`. They read `_time` (double) and + `GetOrbitPosition(parent)`, so no caller passes a float time. +- **Per-file changes:** + - `Zone.cs:149-179`: `Update` loses the belt block entirely. + - `MiningTool.cs:59-62` and `ResourceScanner.cs:80`: read `Zone.ChunkPose` instead of `Transforms`. No behaviour + change; they die or reshape in Cut 2. + - `ZoneRenderer.cs:447-477`: per visible belt, `Zone.EvaluateBelt` into a renderer-owned `float4[]` (allocated in + `LoadPlanet`, `:340-362`, sized to the belt). The matrices are built from that buffer as before. The visibility + bounds use `Zone.GetOrbitPosition(parent)` in place of `belt.OrbitPosition` (`:449-452`). + - `ZoneRenderer.cs:660-676`: `AsteroidBeltUI.Update` takes the renderer's buffer, and its bounds use its own + `_orbitParent` (`:593,613`) through `_zone.GetOrbitPosition`. +- **Behaviour change, named:** poses are read at the current time instead of one tick behind. The measured maximum + difference is 0.21 u in the probe zone. No sim reader is reachable (MiningTool has no writer). The renderer draws + the rocks where they are now. +- **Authority map:** + - Owner: `AsteroidBelt.Pose` and `AsteroidBelt.Size`. + - Inputs: the asteroid record, the parent orbit position at zone time, zone time (double), `PlanetSettings`, and + wear (size only). + - Outputs: position, rotation, size, on request. + - Derived state: every renderer buffer is display-only, owned by `ZoneRenderer`. + - Forbidden writers: any stored per-asteroid pose in `ServerShared`, any `Task` or thread in `Zone`, and any + second copy of the orbit formula. + - Shared paths: renderer, minimap (`AsteroidBeltUI`), and every sim reader, through `Zone`. + - Deletion line: the listed deletes land in the same commit as the adds. No reader of `Transforms` survives. +- **Verification:** + - builds: `Aetheria.Shared`, `tests/Aetheria.Shared.Tests`, `tools/AetherDb`, each with both roots (`-p:CultLibRoot=... + -p:CultMathRoot=...`). The Unity compile is Self's or the operator's step, never batchmode against the main tree. + - tests (new `MiningCut1Tests.cs`; fixture: an `OrbitData` and an `AsteroidBeltData` upserted into a run-store + cache, with a non-degenerate belt of at least 20 asteroids at distinct distances, phases and sizes, and + authored-shape `PlanetSettings` curves, not defaults): + - `ChunkPoseIsTheOrbitAtZoneTime`: after N updates of uneven dt, every chunk's pose equals the closed-form orbit + at the zone's time, including the parent's motion. Pins no staleness. + - `ChunkPoseDoesNotDependOnUpdateCadence`: one update of 1.0 s and 60 updates of 1/60 give equal poses. Pins + pure function of time. + - `DamagedChunkShrinksAndABrokenOneHasNoSize`: pins the size rule moved from `:259-267`. + - Stryker over `AsteroidBelt` and `Zone.Chunk*`. Every survivor is triaged. + - negative: `rg -n "Task\.Run|System\.Threading|NewTransforms|\.Transforms\b|BeltUpdates|NearestAsteroid|\.OrbitPosition\b|NewOrbitPosition" Assets/Scripts/ServerShared/Zone.cs "Assets/Scripts/Zone Display" Assets/Scripts/ServerShared/Behaviors` + returns only `Zone.cs:301`, a commented line inside `MineAsteroid` that Cut 2 deletes. After Cut 2 it is empty. + Tested against `8bd25f6f`: every other hit is a line this cut deletes or rewrites. Widening it to `Assets/Scripts` + collides with `Galaxy.cs:6-7` (`System.Threading`, a legitimate use) and with `followOrbitPosition` + (`ActionGameManager.cs:841-843`). + - probe: `miningprobe` after the cut shows belt cost 0 and 0 races over 10 runs. Record the after numbers. + - operator: fly through a belt. The rocks orbit smoothly, the minimap asteroid overlay tracks them, and the map + screen shows them. +- **Operator questions:** none (the CPU renderer is the stated default). + +--- + +### Cut 2. The dead mining path goes, and chunks get one owner + +- **Repo/branch:** Aetheria, `codex/mining`. Depends on Cut 1, Q1 and Q3. Written for Q1=A and Q3=A. +- **Deletes first:** + - `Behaviors/MiningTool.cs` (76) and its `.meta`. + - `Behaviors.cs:173` `Union(26, typeof(MiningToolData))`, becoming a `// Union(26, MiningToolData): retired, do not + reuse` comment, per the file's own precedent at `:172`, `:174`. + - `Zone.cs:297-335` `MineAsteroid` (39); `:247` `AsteroidExists` (1); `:33,71` `_random` (2); `:510-512` the three + dictionaries (3). + - `ResourceScanner.cs:71-107`, the survey `Execute` (37), plus `ScanTarget`, `Asteroid`, `_scanTime` and + `_scanTarget` (`:38-59`). Its data (`Range`, `MinimumDensity`, `ScanDuration`) stays until Q2 rules. If Q2 goes + against the scanner-as-chunk-sensor, the whole behaviour parks at a tag instead + (`parked/mining-resource-scanner`), because it has legacy content. + - `PlanetSettings.MiningDifficulty` (`Settings.cs:32`). Its only reader is `MineAsteroid`. + - About 160 lines in total. +- **Adds:** + - `ChunkId` (readonly struct, `(CultRecordKey Field, int Index)`, with value equality). + - The wear store on `Zone`: `ChunkWear { float Damage; double? BrokenUntil }` keyed by `ChunkId`. + - `Zone.ChunkExists(ChunkId)`: the index is in range and the chunk is not broken at zone time. + - `Zone.ChunkRadius(ChunkId)`: `Size` from Cut 1, reading wear. + - `Zone.Wear(ChunkId, float damage)` → whether it broke. It is the only writer, and Cut 4's Apply is its only + caller. + - Lazy respawn: an entry whose `BrokenUntil <= time` reads as absent. + - `ZonePack` key 6: `List` (belt key, index, damage, broken-until), nullable. It is written by + `PackZone` (`Zone.cs:131-142`) with expired entries pruned, and read in the constructor after belts are built + (`:91-92`). +- **Authority map:** + - Owner: `Zone` owns chunk wear. `Zone.Wear` is its only writer. + - Inputs: damage delivered at Apply, zone time, `AsteroidHitpoints` and `AsteroidRespawnTime` (`Settings.cs:29-30`). + - Outputs: `ChunkExists`, `ChunkRadius`, and the packed wear. + - Derived state: respawn is a comparison, not a timer. + - Forbidden writers: any per-tick respawn loop. `MineAsteroid`, `MiningTool` and `ResourceScanner.Execute` are + deleted. + - Shared paths: live play, save (`RunSave.Capture` → `PackZone`), continue (the `Zone` constructor), and zone + re-entry. + - Deletion line: the deletes above land before the wear store appears. +- **Verification:** + - tests: + - `WearAccumulatesAndBreaksAtHitpoints` pins the threshold (strictly `>` as `:314`, or `>=`; say which, and pin it). + - `ABrokenChunkReturnsAtItsRespawnTime` pins the lazy respawn at the authored curve. + - `WearSurvivesPackAndUnpack`: a zone round trip through `ZonePack` MessagePack keeps damage and broken-until. + - `APackOmitsExpiredWear`. + - `ARunWithoutKeySixLoadsWhole`: nil key 6 means no wear. + - negative: `rg -n "MiningTool|MineAsteroid|MiningAccumulator|RespawnTimers|MiningDifficulty|AsteroidExists" Assets/Scripts tests tools` + is empty. + - catalog: `AetherDb census` before and after is byte-identical. No live item carries tag 26 **(probe)**. + - operator: none (nothing reachable changes). + +--- + +### Cut 3. A chunk is a target, and is detected by reflected light + +- **Repo/branch:** Aetheria, `codex/mining`, after M-Soul. +- **Written for:** Q13 = A with the detection correction, Q14 = A, Q16 = B (all ruled). + +#### How detection works today (read at `b66ba524`) + +1. **Emitters write into `Entity.VisibilitySources`** (`Entity.cs:63`, a `Dictionary`). Every + entry is one source's contribution: + - `Reflector.cs:46`: `CrossSection × Zone.GetLight(Position.xz)`. This is the rule the operator named. + - Black-body radiation: `Entity.cs:1269`. + - Weapons: `InstantWeapon.cs:257`, `ConstantWeapon.cs:166`. + - Thrusters: `Thruster.cs:110-111`. + - Sensor pings: `Sensor.cs:117`. + - `Visibility.cs:44` and `Radiator.cs:121`. + + Sources decay each tick (`Entity.cs:1083-1087`, `VisibilityDecay` 0.5). +2. **`Entity.Visibility`** (`Entity.cs:136`) is the sum of the sources. +3. **Each `Sensor` behaviour on an observer** loops over every entity in the zone, every tick + (`Sensor.cs:157-189`). + - It adds a gain into `EntityInfoGathered[target]` (`Entity.cs:64`, a `ReactiveDictionary`). + - Passive gain is `target.Visibility × Sensitivity × SensitivityCurve(angle/π) × dt / dist` (`:179-184`). + - Ping gain is `target.Visibility × Sensitivity × PingBoost × dist`, once per ping (`:170-175`). + - It then decays: `next *= 1 − TargetInfoDecay × dt` (`:186`; `TargetInfoDecay` 0.5 in shipped + `Settings.asset`). +4. **Crossing `TargetDetectionInfoThreshold`** (0.1) adds the target to or removes it from `VisibleEntities`, via + the `ObserveReplace` subscriber at `Entity.cs:237-253`. +5. **Fire control reads both.** `PFire` gates on `VisibleEntities.Contains` (`FireControl.cs:173`) and prices + `PSensor` from the info value (`:179`). The same info also gates the armour and gear reveal thresholds + (0.5 and 0.8) through `IsRevealed`. +6. **Light** is `Zone.GetLight(float2)` (`Zone.cs:369` @`283ce7dc`). It sums `PowerPulse(d / LightRadius, 8)` + over every sun within its `LightRadius`. Beyond every sun's radius, light is 0. + +#### How a chunk plugs in, without becoming an entity + +Two things are needed: +- **A chunk's visibility**, `V(chunk, t)`: `cross-section(chunk) × GetLight(pose(chunk, t))`. This is + `Reflector.cs:46`'s rule, applied to a pose that is already a pure function of time (Cut 1). No stored source, + and no decay: a chunk has only the reflected term, because rocks carry no thrusters, weapons or heat + (black-body emission is out of scope, per the operator's correction). +- **An observer's info on a chunk.** The gain rule is the existing one. Q14 decides how it integrates over time. + Recommended: the closed-form equilibrium of the per-tick rule, evaluated on demand, with no per-tick state. + +**No adapter, and why that is the goal.** The adapter would be a pseudo-entity per chunk, or an `IDetectable` +wrapper, so that `Sensor.Execute`'s loop over `Zone.Entities` sees rocks. That is rejected option C of the Codex +Q2 in a costume: +- It puts every chunk into every sensor's per-tick loop. The map measured 184 ns per observed body per tick, + which is 149 µs per sensor-bearing ship for the 809-chunk probe zone. +- It puts chunks into `EntityInfoGathered`, whose `ObserveReplace` subscriber builds `VisibleEntities`, + `VisibleEnemies` and `VisibleFriendlies`. Every consumer of those collections would then have to learn that + some "entities" are rocks. + +The shared owner has to be **the rule, not the container**. The cut extracts the gain terms at `Sensor.cs:170-184` +into one static function, and both paths call it: +- the entity loop, per tick, unchanged in behaviour; +- the chunk query, on demand. + +Nothing about a chunk is stored per observer. + +#### Deletes first + +- The Codex scanner-as-sensor plan: no `ChunkDetectionRange`, no `UnaidedChunkRange`, and no scanner read + anywhere in the target path. +- The v1 placeholders: unconditional chunk visibility, and PSensor fixed at 1. +- Optional, Self's call: the parked `ResourceScanner` behaviour still runs a per-tick `Update` over three stats no + code reads (`ResourceScanner.cs:35-58` @`283ce7dc`). Delete the `Update` and the properties, and keep + `ResourceScannerData` authored. + +#### Adds + +- **`Sensor.Gain(...)`**, a static function extracted verbatim from `Sensor.cs:170-175` (ping) and `:179-184` + (passive). Inputs: target visibility, sensitivity, the curve at the angle, distance, dt, and whether this is a + ping. `Sensor.Execute` calls it. This is a pure extraction. +- **`FieldKindData`**, a new catalog document type (Q11: authored catalog records). Cut 3 introduces it with only + what detection needs: + - `Name` (`[CultName]`); + - `CrossSection` (float): reflectivity per schematic cell of chunk area; + - `GenerationWeight` (float), for Q16. + + Cut 5 adds the yield entries. Only one kind exists at first, "Asteroid". +- **The belt names its kind.** `AsteroidBeltData` gains a nullable `CultRecordRef Kind`, at the next + free key after `Asteroids` `Key(9)` (`ZoneData.cs:101-105` @`283ce7dc`). + - `ZoneGenerator` writes it at generation, through one function `FieldKinds.Assign(belt key, catalog)`: a + `StableHash` pick weighted by `GenerationWeight`. + - Under Q16 = B, a belt loaded without a kind is assigned by the same function on first load and written back. + One derivation serves both the generation path and the load path. +- **`Zone.ChunkVisibility(ChunkId)`** = `kind.CrossSection × π(ChunkRadius / SchematicCellSize)² × GetLight(ChunkPose.xy)`. + - Area in schematic cells keeps the numbers comparable to hull reflectors. The census has Longinus at 500-150 + over 66 cells and Djinni at 2500-500 over 166, so about 2-15 per cell. + - A worn chunk shrinks and dims. A broken chunk has radius 0, so it is invisible. +- **`Entity.ChunkInfo(ChunkId)`**: the info this observer has on the chunk, from its active `Sensor` behaviours + through `Sensor.Gain`, integrated per Q14. Visible means `ChunkInfo > TargetDetectionInfoThreshold`. +- **`TargetRef`**: a readonly struct holding `Entity Entity` and `ChunkId? Chunk`, with value equality and `IsNone`. + It lives beside `ChunkId` (`Zone.cs:527` @`283ce7dc`). +- **`Zone.ChunksNear(float2 position, float range, List into)`**: the one chunk query. + - Belts whose annulus cannot reach are skipped. + - The rest are evaluated through `ChunkPose` (`Zone.cs:285`). + - Broken chunks are skipped through `ChunkExists` (`:231`). + - It knows nothing about detection. Callers filter by `ChunkInfo`. +- **`Zone.ChunkVelocity(ChunkId)`**: analytic velocity plus the parent `Orbit.Velocity`. +- **`Entity.SetTarget(TargetRef)`**: the one writer. The player's handlers and the Cut 5 test agent both call it + (target invariant 3, "set by one writer path that both the player and the AI call"). Today every handler writes + `Target.Value` directly (`ActionGameManager.cs:377,385,395,403`), so there is no one writer to share. +- **Reach (Q13 = A):** cycling and any programmatic chunk pick use `ChunksNear(position, longest active weapon + Range.Max)`, then filter to chunks the observer can see. + +#### Per-file changes (`b66ba524` unless marked) + +- `Sensor.cs:170-184`: call `Sensor.Gain`. Behaviour must stay identical; the whole existing suite is the check. +- `Entity.cs:46`: `Target` becomes `ReactiveProperty`, written only through `SetTarget`. +- `Entity.cs:201`, `:227`: entity removal clears the slot only when it holds that entity. +- `Entity.cs:229`: `TargetedBy` fires for entities only. +- `Entity.cs:233`: `TargetedByCount` counts entity targets only. +- **`Entity.cs:298-299`:** today it clears a target missing from `EntityInfoGathered`, which would clear every + chunk target on the next pass. For a chunk, it clears when `ChunkInfo ≤ TargetDetectionInfoThreshold`, the same + "lost track" rule expressed through the chunk path. A broken chunk has 0 visibility, so it is lost the same way. +- `Entity.cs:317`: `TrySelectTargetItem` is entity-only. +- `Entity.cs:1078`: `TargetRange` reads the chunk position for a chunk. +- `ActionGameManager.cs:365-377` (reticle pick): pick the chunk under the reticle from `ChunksNear` at the + reticle, if visible. +- `ActionGameManager.cs:381-388` (`TargetNearest`): stays enemies-only. It drives lock. +- `ActionGameManager.cs:390-404` (next and previous): include visible chunks within Q13's reach. +- `ActionGameManager.cs:412-414` (Cycle Target Item): entity-only. +- `ActionGameManager.cs:582`, `:745`, `:769`, `:1046`, `:1224`, `:1239`, `:1266-1267`, `:1355-1373`: `.Entity` + reads, or the chunk position for the indicator. +- `ActionGameManager.cs:1285`: `UpdateFireControlDebug` takes `TargetRef`. +- `Weapon.cs:101` `StanceAllowsFire`: true for a chunk. `Weapon.cs:110` `ArcAllowsFire`: position through the ref. +- `LockWeapon.cs:86`: a chunk never locks (Q12 = A, ruled). +- `TurretController`, `Combat.cs`, `Minion.cs`, `Ship.cs`, `EntityInstance.cs`, `PropertiesPanel.cs`, + `FieldDriver.cs`, `ActionBarSlot.cs`: `.Entity` and null checks. + +#### Authority map + +- **Owners:** + - `Entity.Target` is the one slot, and `SetTarget` is its one writer. + - `Sensor.Gain` is the one detection gain rule. + - `FieldKindData` owns a chunk's reflectivity. + - `FieldKinds.Assign` owns which kind a belt is. +- **Inputs:** player handlers and the test agent; sensor stats; chunk pose and radius; sun light. +- **Outputs:** the current `TargetRef`; `ChunkVisibility`; `ChunkInfo`. +- **Derived state:** + - `ChunkVisibility` and `ChunkInfo` are pure functions of time and are never stored. + - `TargetItem` is entity-only. + - `TargetRange`. +- **Forbidden writers:** + - any second target field; + - any direct `Target.Value =` outside `SetTarget`; + - any chunk in `Zone.Entities` or `EntityInfoGathered`; + - any per-tick chunk detection loop; + - any chunk-specific copy of the gain arithmetic; + - any unconditional visibility for chunks. +- **Shared paths:** reticle, cycling, the test agent, dock and undock re-activation, and belt generation and load + (through `FieldKinds.Assign`). +- **Deletion line:** no `ReactiveProperty Target` remains; no `Target.Value =` outside `SetTarget`; no + gain arithmetic outside `Sensor.Gain`. + +#### Verification + +Yggdrasil, the M0 command with `--filter FullyQualifiedName~MiningCut3`, then the full suite. + +| Test | Rule it pins | +|---|---| +| `SensorGainExtractionChangesNothing` | The existing detection tests, and a recorded `EntityInfoGathered` trace over N ticks for a fixed scene, are byte-equal before and after the extraction. | +| `ChunkInfoMatchesAnEntityHeldStill` | Q14's parity. An entity with a `Reflector` of the same cross-section, at the chunk's position and held still, converges under the per-tick loop to the value `ChunkInfo` returns (tolerance from the discrete fixed point). **One rule, two integrators.** | +| `AChunkInDarknessIsNotVisible` | A chunk outside every sun's `LightRadius` has `ChunkVisibility` 0 and cannot be targeted or fired on. | +| `ABrighterOrBiggerChunkIsSeenFurther` | Monotone in cross-section, radius and light. | +| `AWornChunkDims`, `ABrokenChunkIsInvisible` | Visibility follows `ChunkRadius`. | +| `CyclingReachIsTheLongestActiveWeaponRange` | Q13. A visible chunk beyond it is not offered. | +| `AChunkTargetSurvivesTheInfoPassWhileSeen` | The negative for `Entity.cs:298-299`. It fails on the naive retype. | +| `AChunkTargetIsDroppedWhenItGoesDark` | The lost-track rule, through the chunk path. | +| `TargetRefEquality` | Value equality; a chunk never equals an entity. | +| `ChunkTargetIsNeverLocked` | Q12 = A. | +| `FieldKindAssignmentIsOneFunction` | Generation and first-load assignment give the same kind for the same belt key; the kind is written back once. | +| every existing fire-control and sensor test | Pass unchanged. This is the negative that ship detection and targeting did not move. | + +Also: +- Negative greps: `git grep -n "ReactiveProperty Target\b"` and `git grep -n "Target.Value ="` outside + `Entity.SetTarget` are empty. +- Stryker over `Sensor.Gain`, `Zone.ChunkVisibility`, `Entity.ChunkInfo`, `ChunksNear` and `TargetRef`. +- Census before and after: `AsteroidBeltData` is a run-store type and is absent from the catalog, and + `FieldKindData` shows one record once Cut 7's content lands. Until then, Cut 3 ships a test fixture kind and a + one-record catalog write through `AetherDb`, flagged for operator review. +- Unity compile: Self. +- Operator: + - target a lit rock with the reticle, and cycle through rocks and ships; + - fly into a belt far from the sun and confirm its rocks cannot be picked; + - dock and undock with a rock held. + +#### Subtraction estimate + +About 0 removed (optionally about 15 for the scanner `Update`). About 140 added: +- `Sensor.Gain` extraction nets about 0; +- `FieldKindData` plus assignment, about 40; +- `ChunkVisibility` and `ChunkInfo`, about 30; +- `TargetRef`, `ChunksNear`, `SetTarget`, about 70. + +Plus about 95 mechanical site edits. One new catalog document type (`FieldKindData`), and one nullable +run-store slot (`AsteroidBeltData.Kind`). + +### Cut 4. A shot at a chunk goes through FireControl, and blasts cover chunks (re-anchored to 12.4) + +- **Repo/branch:** `codex/mining`, after Cut 3. +- **Written for:** Q10 = A, Q12 = A (both ruled). +- **Lands after:** the fire-control no-target fuse ruling (`docs/fire-control-cut.md`, 12.4 status, "a fused + weapon fired with no target bursts at the shooter's own position", `FireControl.cs:389`). That ruling's fix + edits the same `target != null` branches this cut retypes (`:367`, `:372-378`, `:389`). If the ruling is still + open, this cut preserves today's behaviour for the no-target case and says so in its commit. + +**Deletes first:** none. `PendingShot.Target` (`:1468`) and `ShotOutcome.Target` (`:1598`) become `TargetRef`. + +**Adds:** +- **`Silhouette.Disc(float radiusCells, float precision)`.** The shared integrator is extracted from `Silhouette` + `:1302-1308` (the `Phi` sum over merged intervals, and `span`). The hull path and the disc path call one + function. No second `Phi` summation exists. +- **A chunk pass inside `Detonate`** (Q10 = A). After the entity pass (`:1084-1149`): + - `ChunksNear(worldPlanar, radius + max chunk radius)`. + - Each covered chunk takes `damage × CircleCircleOverlap(blast, chunk) / (π r_blast²)`, the same normaliser the + cell shares use (`:1092`). + - `CircleCircleOverlap` is the closed-form lens area, about 12 lines, beside `CircleSquareOverlap` (`:1153`). + - Each share goes to `Zone.Wear`. + - **One area rule:** a blast's disc is divided over whatever it covers, whether cells or chunks. Conservation + holds across both. +- **`Detonate` gains an optional loot sink**, the detonating shot, so the shooter receives the ore (Q10 = A). + - A sourceless blast passes null. None exists today, but a mine is one (F12-6). A null-sink blast wears chunks + and yields nothing. + - This is the only new parameter. `Detonate` still reads no host (12.4's rule). + +**Per-file changes** (`FireControl.cs` @`b66ba524`): +- `PFire` `:161-181`: + - For a chunk, the visibility gate (`:173`) is `ChunkInfo > TargetDetectionInfoThreshold`, and `info` (`:179`) + is `ChunkInfo` (Cut 3). **This replaces v1's "no gate, info 1".** + - `PSensor` prices a chunk exactly as it prices a ship at the same info. + - A `LockWeapon` is gated out (`:175`, Q12 = A). The arc stays (`:176`). +- `Forecast` `:206`, `HitProbability` `:228`, `Inspect` `:243-283`: the disc silhouette. `Inspect`'s `Visible` and + `Info` come from the chunk path. +- `PredictedIntercept` `:133`, `TravelDirection` `:307`: position and velocity through the ref. `Bearing` `:316` + is not called for a chunk, because a disc has no facing. +- `DeviationProbability` `:336-341`: the live chunk position is `Zone.ChunkPose` at `now`. +- `Fire` `:355-418`: freezes the ref. +- `Step` `:439`: `targetGone` includes `!ChunkExists`. +- `CommitProbability` `:506-520`: for a chunk, the disc (no `GetData(Hull)` at `:516`). +- `Commit` `:550-600`: for a chunk hit, no `Lane` (`:563-578`) and no shield block (`:585-592`). `Cell`, `Bearing` + and `Lateral` stay zero in `MakeOutcome` (`:1028`). +- `Apply` `:651-678`: + - Null fuse on a chunk: `Zone.Wear(chunk, Damage)` (`Zone.cs:260` @`283ce7dc`). + - Contact or delayed fuse on a chunk: no `ApplyBlastHit`, which needs a hull (`:747-760`). `Detonate` at the + chunk's centre at arrival. The pose is a pure function of time, so the point is exact. + - Proximity: `Detonate` as today, now with the chunk pass. + - Contact or delayed on a ship: `ApplyBlastHit` as today, and its `Detonate` now also covers nearby chunks. +- Unity presentation: `EntityInstance.cs` (4 target reads) and the four weapon managers aim at the chunk's + rendered position. Presentation only. + +**Authority map:** +- **Owner:** `FireControl`, unchanged. `Detonate` stays the one area owner. +- **New inputs:** chunk pose, velocity, radius and info, through `Zone` and `Entity.ChunkInfo`. +- **When blast damage to chunks is decided:** at detonation. Each covered chunk is judged where it is at arrival, + which is 12.4's own bystander rule (`ApplyBlastHit`'s comment at `:773-777`: "every bystander is judged against + where it actually is at arrival"). A direct hit on a chunk is still decided at `Commit`. +- **Forbidden writers:** + - any chunk hit test outside `Commit`, except `Detonate`'s area pass; + - any second area-share formula; + - any second `Phi` integrator; + - `Detonate` reading a host. +- **Shared paths:** player trigger, the Cut 5 test agent, AI `Activate` (`Combat.cs:121`), and turrets (which + never hold a chunk). Any blast from any source goes through `Detonate`. +- **Deletion line:** none. + +**Verification** (Yggdrasil, `--filter FullyQualifiedName~MiningCut4`, then the full suite): + +| Test | Rule it pins | +|---|---| +| `AChunkShotUsesTheSameFactors` | `Accuracy`, `PSpread`, `Sigma` and `PSensor` equal a ship shot's at equal range, precision and info. One fire path. | +| `AnUnseenChunkCannotBeFiredOn` | The gate is the detection rule, not a constant. | +| `DiscMassIsTheClosedForm` | `POnHull = Φ(R/σ) − Φ(−R/σ)`: the shared integrator. | +| `HullSilhouetteUnchangedByTheExtraction` | Every existing `Silhouette` test and the `ProbeC` equivalence still pass. The negative for the extraction. | +| `AChunkThatBreaksMidFlightIsAMiss` | `Step`'s gone rule. | +| `ChunkHitsWearTheChunk`, `AHitOnABrokenChunkChangesNothing` | Wear; re-pins `34994732` through the real path. | +| `ABlastConservesAcrossCellsAndChunks` | The shares delivered to cells plus chunks, plus the uncovered area, equal the damage. Same lattice style as 12.4's 1,944-disc conservation probe. | +| `ABlastBesideAnUntargetedRockWearsIt` | Q10 = A. | +| `CircleCircleOverlapIsTheLens` | Closed form, with the tangent, contained and disjoint cases (12.4's F1 tangent lesson). | +| `LaunchersCannotFireOnAChunk` | Q12 = A. | +| `ChunkDiceAreTheShotsOwn` | The same galaxy and shot id give the same outcome. | + +Also: Stryker over the new branches. Operator: shoot a lit rock with each damage type; it shrinks and breaks, +and misses at range look plausible. + +**Subtraction estimate:** about 5 removed (the integrator extraction) and about 115 added (the chunk pass and the +lens area add about 35 over v1). + +--- + +### Cut 5. Composition, the loot roll, and the mined lot + +- **Repo/branch:** Aetheria, `codex/mining`. Depends on Cut 4 and Q4-Q7. Written for A in each. +- **Deletes first:** `BodyData.Resources` (`ZoneData.cs:53-54`), becoming a retired-key comment (key 4). Its reader + `MineAsteroid` is already gone. Under Q7=B it stays instead, gets a writer, and the jitter is derived. +- **Adds:** + - `SimpleCommodityData` key 12 `Dictionary Affinity` (nullable; absent means 1). Key 13 + `float? Abundance` (null means not a belt ore). + - `PlanetSettings.AsteroidYield` (`ExponentialLerp`) and `ChunkCompositionVariance` (float). + `GameplaySettings.MiningDepthPerPenetration` (float). + - `Zone.ChunkComposition(ChunkId, Span<...>)`: derived (Q7), from belt-ore abundances times the `StableHash` jitter. + - The loot roll inside `Commit`, after the hit draw, from the same per-shot generator: first the units draw, then + one draw per unit for the ore. The result is frozen into `ShotOutcome` as `Loot` (commodity and count pairs, + presentation-readable). + - `ItemManager.ExtractedLot(int zone, CultRecordKey belt, CultRecordRef)`: find-or-mint, + using an index derived from the ledger at construction. + - `Extracted` key 2 `CultRecordRef Body`. + - `ItemInstance` gains `Lot` at key 11. It moves from `CraftedItemInstance` (`ItemInstance.cs:38`), so the key is + byte-identical per provenance probe Q4. +- **Per-file changes:** + - `FireControl.Apply`: after `Zone.Wear`, mint or find the lot and `Source.CargoBays.Any(c => c.TryStore(unit))`. + Overflow is lost. + - `Entity.cs:1838`: merge only when `Data` and `Lot` both match. `:1916`: the split copies `Lot`. + - `SavedGame.cs:110-114`: the roots are every `ItemInstance` with `Lot != 0`. + - `ItemManager.cs:72` `GetLot` takes `ItemInstance`. +- **Authority map:** + - Owners: `FireControl.Commit` decides the loot. `ItemManager` mints lots. `EquippedCargoBay` places units. + - Inputs: frozen damage, type and penetration; the chunk composition at commit; remaining HP at commit. + - Outputs: `ShotOutcome.Loot`, units in cargo, ledger lots. + - Derived: composition, and the source→lot index. + - Forbidden writers: any loot draw outside `Commit`; any mined `SimpleCommodity` without a lot; any shared random + stream. + - Shared paths: player and AI shots; save GC; continue (the index is rebuilt). + - Deletion line: `BodyData.Resources` retired before the composition function is added. +- **Verification:** + - tests: + - `RightDamageTypeRaisesTheOresShare` (the Q4 table as a fixture). + - `PenetrationFlattensTowardRareOre`. + - `ZeroPenetrationReadsTheRawComposition`. + - `AChunkYieldsItsAuthoredTotalWhateverBreaksIt` (Invariant 7: the sum over many shots from different weapons). + - `LootIsDecidedAtCommitAndPerformedAtArrival`. + - `TheSameShotYieldsTheSameOre`. + - `MinedUnitsCarryAnExtractedLotNamingZoneAndBelt`. + - `UnitsFromOneSourceStack`, and `UnitsFromTwoSourcesDoNot`. + - `ASplitStackKeepsItsLot`. + - `MinedLotsSurviveSave` (GC roots). + - `AFullHoldLosesTheOverflow`. + - `CompositionIsStableAcrossContinue`. + - Stryker over the formula and the mint. + - negative: `rg -n "\.Resources\b" Assets/Scripts/ServerShared` returns no `BodyData` reads. + - catalog: `AetherDb census` shows keys 12 and 13 as `defaulted_missing_slot` until Cut 7 writes them. That is the + compatible drift fire-control Cut 12 already measured. + - operator: mine one rock with a Thermal gun and one with a Kinetic gun, and read the cargo. + +--- + +### Cut 6. Deleted (Q9 = C) + +AI mining is deferred, and its same-path proof is the test agent in Cut 5. The original Cut 6 text (`MiningState`, the +`Mining` task, the locomotion Cut 4 dependency) is in git history at `22fb4021` and in +`docs/mining-cut-refresh.md`, Cut 6. The campaign that builds AI mining starts from `Zone.CreateAgent`, the `Minion` +transitions, `Entity.SetTarget` and `Combat.cs:121`. + +--- + +### Cut 7. Content (operator review) + +- A content cut through an `AetherDb` command (`mining-content [apply]`, dry run by default, one catalog commit; + precedent: `targeting-catalog`). The operator reviews a sheet before `apply`: + - The 14 legacy ores missing from live (Crystal, Silicon, Carbon, Nitrogen, Iron, Titanium, Copper, Lead, Ethanol, + Hydrocarbons, Acid, Oxygen, Carbon Dioxide, Plant Matter), restored with their legacy mass, specific heat, + conductivity and category **(probe values in `legacy-0414`)**. The 3 missing ammo types are out of scope. + - `Abundance` and `Affinity` for each belt ore, over the four damage types that exist (Q8). + - The Drill Bit as a mining weapon (Q1), restored from legacy with its authored ranges. + - `AsteroidYield`, `ChunkCompositionVariance`, and `MiningDepthPerPenetration` values. + - The Corrosive and Ionizing gap, recorded (Q8). +- **Verification:** `census` and `dangling` before and after. The loot-table fixture from Cut 5 runs against the live + catalog, asserting that every belt ore is reachable by at least one live weapon's damage type. + +--- + +## Subtraction ledger (estimate) + +| Cut | Removed | Added | Formats and targets | +|---|---|---|---| +| 1 | ~65 (`Zone.cs`), ~12 (`ZoneRenderer.cs`) | ~35 | threading removed; no new target | +| 2 | ~160 (`MiningTool.cs`, `MineAsteroid`, survey `Execute`, dictionaries, `_random`, `MiningDifficulty`) | ~70 | union 26 retired; `ZonePack` key 6 added | +| 3 | ~0 | ~80, plus ~60 mechanical site edits | `Target` retyped | +| 4 | ~0 | ~70 | `PendingShot`/`ShotOutcome` target retyped | +| 5 | 2 (`BodyData.Resources`) | ~150 | `SimpleCommodityData` keys 12 and 13; `Extracted` key 2; `ItemInstance.Lot` moves to the base | +| 6 | deleted (Q9 = C) | 0 | none | +| 7 | 0 | content only | catalog records | + +Net code is roughly flat against about 240 lines deleted. Every addition buys a named capability: targetable chunks, +the loot roll, provenance, AI mining. + +## Build budget + +Headless: `Aetheria.Shared` (netstandard2.1), `tests/Aetheria.Shared.Tests` (net10.0), and `tools/AetherDb` +(net10.0), on the Windows workstation. Nothing ships from them. Both CultLib roots are passed explicitly. Unity +compiles are Self's or the operator's, on the main tree, never batchmode while the editor holds the project. No new +package, assembly or executable target. diff --git a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs index 921961b15..806f153d7 100644 --- a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs +++ b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs @@ -183,7 +183,7 @@ public sealed class FireAuthorityTests : IDisposable shooter.Position = float3.zero; target.Position = float3(0, 0, targetRange); - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.EntityInfoGathered[target] = 1f; shooter.SetIff(target, true); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs index ab2966025..24ae6cd4a 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs @@ -184,7 +184,7 @@ public sealed class FireControlCut11Tests : IDisposable shooter.Position = float3(37, 0, -11); target.Position = float3(37, 0, 89); // 100 units straight ahead of the shooter - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.EntityInfoGathered[target] = 1f; shooter.SetIff(target, true); if (equipShield) target.Shield.Item.Enabled.Value = true; diff --git a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs index b01d04b76..7faac21ce 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs @@ -233,7 +233,7 @@ public sealed class FireControlCut123Tests : IDisposable shooter.Position = float3(37, 0, -11); target.Position = float3(37, 0, 89); // 100 units straight ahead of the shooter -- travel direction is exactly (0,1) if (targetFacing != null) target.Direction = targetFacing.Value; - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.EntityInfoGathered[target] = 1f; shooter.SetIff(target, true); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index 5811508d3..92b26bbce 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -253,7 +253,7 @@ public sealed class FireControlCut124Tests : IDisposable shooter.Position = float3(-53, 0, -101); target.Position = shooter.Position + float3(0, 0, targetRange); if (targetFacing != null) target.Direction = normalize(targetFacing.Value); - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.EntityInfoGathered[target] = 1f; shooter.SetIff(target, true); @@ -1176,7 +1176,7 @@ public sealed class FireControlCut124Tests : IDisposable var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 4f, penetration: 1f, damage: 100f, weaponRange: NoLockRange); Aim(e, float2(2, 1)); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); var burst = AimLine(e); var onLine = AddShip(e, burst, 1f, 300); var offLine = AddShip(e, burst + float3(-30, 0, 30), 1f, 302); @@ -1201,7 +1201,7 @@ public sealed class FireControlCut124Tests : IDisposable var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, weaponRange: NoLockRange); Aim(e, float2(2, 1)); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); @@ -1252,7 +1252,7 @@ public sealed class FireControlCut124Tests : IDisposable { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, weaponRange: NoLockRange); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); Aim(e, float2(side, -1)); var edge = float2(side * sin(radians(85f)), cos(radians(85f))); var onEdge = AddShip(e, PointAlong(e, edge, NoLockRange), 1f, 300); @@ -1273,7 +1273,7 @@ public sealed class FireControlCut124Tests : IDisposable { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, weaponRange: NoLockRange); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); var mount = float2(0, 1); var aim = normalize(float2(2, 1)); var onAim = AddShip(e, PointAlong(e, aim, NoLockRange), 1f, 300); @@ -1659,7 +1659,7 @@ public sealed class FireControlCut124Tests : IDisposable public void AContactRoundNeverStopsNearerThanItsArmingDistance() { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: NoLockRange); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); Aim(e, float2(0, 1)); AddShip(e, PointAlong(e, float2(0, 1), 6f), 1f, 300); ShotOutcome outcome = null; @@ -1682,7 +1682,7 @@ public sealed class FireControlCut124Tests : IDisposable var column = new Shape(1, 22); foreach (var y in new[] { 0, 1, 2, 18, 19, 20, 21 }) column[new int2(0, y)] = true; var e = Build(TestSettings(), column, velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: NoLockRange); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); Aim(e, float2(0, 1)); var offset = e.Target.ToWorldPoint(float2(0, 2)) - e.Target.Position.xz; e.Target.Position = float3(e.Shooter.Position.x - offset.x, 0, e.Shooter.Position.z - offset.y); @@ -1708,7 +1708,7 @@ public sealed class FireControlCut124Tests : IDisposable { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, weaponRange: range, rangeTerm: starvedOfPower ? StatSource.PowerSupply : (StatSource?) null); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); Aim(e, float2(0, 1)); if (starvedOfPower) { @@ -1730,7 +1730,7 @@ public sealed class FireControlCut124Tests : IDisposable public void ARangeExactlyEqualToTheArmingDistanceFires() { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, weaponRange: 30f); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); Aim(e, float2(0, 1)); Assert.True(FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter) != 0, "a Range equal to the arming distance must fire"); @@ -1745,7 +1745,7 @@ public sealed class FireControlCut124Tests : IDisposable public void ARefusedRoundIsNotAnnounced(float range, int fired) { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, weaponRange: range); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); Aim(e, float2(0, 1)); var announced = 0; var committed = 0; @@ -1918,7 +1918,7 @@ public sealed class FireControlCut124Tests : IDisposable { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 4f, penetration: 1f, damage: 100f, weaponRange: NoLockRange); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); Aim(e, float2(0, 1)); var blocker = AddShip(e, PointAlong(e, float2(0, 1), 25f), 1f, 300); var atMaxRange = AddShip(e, PointAlong(e, float2(0, 1), NoLockRange), 1f, 302); @@ -1944,7 +1944,7 @@ public sealed class FireControlCut124Tests : IDisposable { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: NoLockRange); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); Aim(e, float2(0, 1)); var targetBefore = e.Target.Hull.Durability; var shooterHit = 0f; @@ -1970,7 +1970,7 @@ public sealed class FireControlCut124Tests : IDisposable { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: NoLockRange); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); Aim(e, float2(0, 1)); var blocker = AddShip(e, PointAlong(e, float2(0, 1), 25f), 1f, 300); ShotOutcome outcome = null; @@ -1999,7 +1999,7 @@ public sealed class FireControlCut124Tests : IDisposable { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, weaponRange: NoLockRange); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); Aim(e, float2(0, 1)); var witness = AddShip(e, PointAlong(e, float2(0, 1), NoLockRange), 1f, 300); @@ -2061,7 +2061,7 @@ public sealed class FireControlCut124Tests : IDisposable { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, weaponRange: NoLockRange); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); Aim(e, float2(2, 1)); ShotOutcome committed = null, resolved = null; using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); @@ -2162,7 +2162,7 @@ public sealed class FireControlCut124Tests : IDisposable public void AContactRoundIgnoresAHullThatCrossesItsLineAfterFire() { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); Aim(e, float2(0, 1)); var blocker = AddShip(e, PointAlong(e, float2(0, 1), 25f) + float3(300, 0, 0), 1f, 300); ShotOutcome committed = null; @@ -2183,7 +2183,7 @@ public sealed class FireControlCut124Tests : IDisposable public void AContactRoundStillBurstsAtAHullThatLeavesItsLineAfterFire() { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); Aim(e, float2(0, 1)); var blocker = AddShip(e, PointAlong(e, float2(0, 1), 25f), 1f, 300); var atMaxRange = AddShip(e, PointAlong(e, float2(0, 1), 60f), 1f, 302); @@ -2209,7 +2209,7 @@ public sealed class FireControlCut124Tests : IDisposable public void AShooterMovingAfterFireDoesNotMoveTheContactPoint(bool withBlocker) { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); Aim(e, float2(0, 1)); var origin = e.Shooter.Position; if (withBlocker) AddShip(e, PointAlong(e, float2(0, 1), 25f), 1f, 300); @@ -2230,7 +2230,7 @@ public sealed class FireControlCut124Tests : IDisposable public void AContactRoundIgnoresAHullBehindTheMuzzle() { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); Aim(e, float2(0, 1)); AddShip(e, PointAlong(e, float2(0, -1), 25f), 1f, 300); ShotOutcome committed = null; @@ -2294,7 +2294,7 @@ public sealed class FireControlCut124Tests : IDisposable public void ANoLockRoundStillBurstsAfterItsShooterIsDestroyed(WeaponFuse fuse) { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: fuse, blastRadius: 4f, damage: 100f, weaponRange: 60f); - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); Aim(e, float2(0, 1)); var witness = AddShip(e, PointAlong(e, float2(0, 1), 60f), 1f, 300); var resolved = new List(); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs index 30cf46356..c613bfa4b 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs @@ -279,7 +279,7 @@ public sealed class FireControlCut12Tests : IDisposable shooter.Position = float3(37, 0, -11); target.Position = float3(37, 0, 89); // 100 units straight ahead of the shooter if (targetFacing != null) target.Direction = targetFacing.Value; - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.EntityInfoGathered[target] = 1f; shooter.SetIff(target, true); if (equipShield) @@ -390,7 +390,7 @@ public sealed class FireControlCut12Tests : IDisposable // No target: Fire's own null-target branch must use the fallback directly, not call TravelDirection // (which reads target.Position and would NRE). - e.Shooter.Target.Value = null; + e.Shooter.SetTarget(TargetRef.None); var noTargetId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); var noTarget = e.Zone.PendingShots.Single(s => s.ShotId == noTargetId); Assert.Equal(0f, noTarget.TravelDirection.x); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs index 5ee04751a..a6c1cc1bc 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs @@ -161,7 +161,7 @@ public sealed class FireControlCut4Tests : IDisposable shooter.Position = float3.zero; target.Position = float3(0, 0, targetRange); - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.EntityInfoGathered[target] = 1f; shooter.SetIff(target, true); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs index e1366721e..d27857104 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs @@ -191,7 +191,7 @@ public sealed class FireControlCut5Tests : IDisposable shooter.Position = float3.zero; target.Position = float3(0, 0, targetRange); - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.EntityInfoGathered[target] = 1f; shooter.SetIff(target, true); @@ -346,7 +346,7 @@ public sealed class FireControlCut5Tests : IDisposable shooter.Position = float3.zero; target.Position = targetPosition; - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.EntityInfoGathered[target] = 1f; shooter.SetIff(target, true); @@ -449,7 +449,7 @@ public sealed class FireControlCut5Tests : IDisposable // The shared path: Weapon.ArcAllowsFire (the trigger gate every shooter passes through) must agree, // now that it no longer carries its own special case and defers to InArc entirely. var weapon = (Weapon) weaponItem.Behaviors.Single(b => b is Weapon); - shooter.Target.Value = target; + shooter.SetTarget(target); Assert.True(weapon.ArcAllowsFire); } @@ -465,7 +465,7 @@ public sealed class FireControlCut5Tests : IDisposable var (_, shooter, target, weaponItem) = BuildPointBlank(settings); shooter.Position = float3.zero; target.Position = float3(100, 0, 0); // directly abeam - shooter.Target.Value = target; + shooter.SetTarget(target); var weapon = (Weapon) weaponItem.Behaviors.Single(b => b is Weapon); Assert.False(weapon.ArcAllowsFire); @@ -488,7 +488,7 @@ public sealed class FireControlCut5Tests : IDisposable var e = Build(settings, damage: 10, velocity: 0, accuracy: 1, resolution: 1, spread: 0, targetRange: 100); e.Shooter.Position = float3.zero; e.Target.Position = float3(100, 0, 0); // abeam -- outside the narrow 60-degree arc - e.Shooter.Target.Value = e.Target; + e.Shooter.SetTarget(e.Target); var resolvedCount = 0; e.Zone.ShotResolved.Subscribe(_ => resolvedCount++); @@ -590,7 +590,7 @@ public sealed class FireControlCut5Tests : IDisposable var observer = new Ship(items, target.Zone, new EquippableItem { Data = observerHullRef, Durability = 1000, Lot = 1 }, new EntitySettings()); target.Zone.Entities.Add(observer); observer.Activate(); - observer.Target.Value = target; + observer.SetTarget(target); // With both items alive: Big (index 0) reveals at .2, Medium (index 1) at .8. observer.EntityInfoGathered[target] = .3f; diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs index 788655802..9698d741d 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs @@ -163,7 +163,7 @@ public sealed class FireControlCut6Tests : IDisposable shooter.Position = float3.zero; target.Position = float3(0, 0, targetRange); - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.EntityInfoGathered[target] = 1f; shooter.SetIff(target, true); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs index 2bd717924..844e4995b 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs @@ -145,7 +145,7 @@ public sealed class FireControlCut6cTests : IDisposable shooter.Position = float3.zero; target.Position = float3(0, 0, targetRange); - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.SetIff(target, true); zone.Update(0f); // warm-up: resolves the targeting system's stats before HitProbability reads them diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs index b45a1d20c..8b9d39cf1 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs @@ -163,7 +163,7 @@ public sealed class FireControlCut6dTests : IDisposable shooter.Position = float3.zero; target.Position = float3(0, 0, targetRange); - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.SetIff(target, true); shooter.EntityInfoGathered[target] = 1f; // full reveal: pSensor = 1 diff --git a/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs index c278bcf58..63578287d 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs @@ -140,7 +140,7 @@ public sealed class FireControlCut7Tests : IDisposable shooter.Position = float3.zero; target.Position = float3(0, 0, 100); - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.SetIff(target, true); zone.Update(0f); // warm-up: resolves weapon/targeting stats before Fire reads them diff --git a/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs index 96fb785d1..a978aed7f 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs @@ -145,7 +145,7 @@ public sealed class FireControlCut8Tests : IDisposable shooter.Position = float3.zero; target.Position = float3(0, 0, 100); - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.EntityInfoGathered[target] = 1f; shooter.SetIff(target, true); @@ -225,7 +225,7 @@ public sealed class FireControlCut8Tests : IDisposable locker.Position = float3.zero; bystander.Position = float3(0, 0, 100); locker.LookDirection = float3(0, 0, 1); - locker.Target.Value = bystander; + locker.SetTarget(bystander); locker.EntityInfoGathered[bystander] = 1f; locker.SetIff(bystander, true); @@ -247,7 +247,7 @@ public sealed class FireControlCut8Tests : IDisposable public void DeadEntityDoesNotUpdate() { var e = Build(TestSettings()); - e.Target.Target.Value = e.Shooter; // give the target its own TargetRange to freeze + e.Target.SetTarget(e.Shooter); // give the target its own TargetRange to freeze e.Zone.Update(.1f); var rangeBeforeDeath = e.Target.TargetRange; @@ -274,7 +274,7 @@ public sealed class FireControlCut8Tests : IDisposable s.Locker.Deactivate(); Assert.False(s.Locker.Active); Assert.DoesNotContain(s.Bystander, s.Locker.EntityInfoGathered.Keys); - Assert.Equal(s.Bystander, s.Locker.Target.Value); // Deactivate does not null the entity's own Target + Assert.Same(s.Bystander, s.Locker.Target.Value.Entity); // Deactivate does not null the entity's own Target var ex = Record.Exception(() => s.Locker.Update(.1f)); Assert.Null(ex); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs index d824d4278..2ebeb72f1 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs @@ -333,7 +333,7 @@ public sealed class FireControlCut9Tests : IDisposable shooter.Position = float3.zero; target.Position = float3(0, 0, 100); - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.SetIff(target, true); shooter.EntityInfoGathered[target] = 1f; // full reveal: pSensor = 1 @@ -561,7 +561,7 @@ public sealed class FireControlCut9Tests : IDisposable locker.Position = float3.zero; bystander.Position = float3(0, 0, 100); locker.LookDirection = float3(0, 0, 1); - locker.Target.Value = bystander; + locker.SetTarget(bystander); locker.EntityInfoGathered[bystander] = 1f; locker.SetIff(bystander, true); @@ -596,7 +596,7 @@ public sealed class FireControlCut9Tests : IDisposable // Assert.Null(entity) would hand a live Entity into xUnit's reflection-based failure formatter, // which recurses through Zone -> Entities -> Ship -> Zone forever; a plain boolean with its own // message sidesteps that entirely. - Assert.True(s.Locker.Target.Value == null, "the invariant: an active entity's Target is always a live entity it has info on -- a stale reference to the departed Bystander survived Activate's reconciliation"); + Assert.True(s.Locker.Target.Value.IsNone, "the invariant: an active entity's Target is always a live entity it has info on -- a stale reference to the departed Bystander survived Activate's reconciliation"); var ex = Record.Exception(() => s.Locker.Update(.1f)); Assert.Null(ex); diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 7d79a99b1..edca68dde 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -245,7 +245,7 @@ public sealed class FireControlPerWeaponTests : IDisposable shooter.Position = float3(-53, 0, -101); target.Position = shooter.Position + float3(0, 0, targetRange); - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.EntityInfoGathered[target] = 1f; shooter.SetIff(target, true); @@ -371,10 +371,10 @@ public sealed class FireControlPerWeaponTests : IDisposable public void TheForecastIsTheOutcomeFireDelivers(float targetRange, float range, bool noTarget, bool outOfArc, FireOutcome outcome, float reach) { var r = Build(outOfArc ? SideMount : null, fuse: WeaponFuse.Proximity, blast: 30f, range: range, targetRange: targetRange); - if (noTarget) r.Shooter.Target.Value = null; + if (noTarget) r.Shooter.SetTarget(TargetRef.None); Aim(r, float2(0, 1)); - var d = FireControl.Inspect(r.Gun.Weapon, r.Shooter, r.Shooter.Target.Value); + var d = FireControl.Inspect(r.Gun.Weapon, r.Shooter, r.Shooter.Target.Value.Entity); var shotId = FireControl.Fire(r.Gun.Weapon, r.Gun.Item, r.Shooter); Assert.Equal(outcome, d.Outcome); @@ -417,7 +417,7 @@ public sealed class FireControlPerWeaponTests : IDisposable private Costs Pull(float range, bool single, bool viaTrigger, bool staleRange = false, bool auto = false) { var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range, energy: 50f, heat: 5000f, visibility: 100f, magazine: 5, singleAmmoBurst: single, auto: auto); - r.Shooter.Target.Value = null; + r.Shooter.SetTarget(TargetRef.None); Aim(r, float2(0, 1)); var weapon = (InstantWeapon) r.Gun.Weapon; var capacitor = (InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(weapon); @@ -496,7 +496,7 @@ public sealed class FireControlPerWeaponTests : IDisposable private (int Shots, int Wears, float Request, bool Visible, float Heat, int Stops, bool Firing) Burn(float range) { var r = Build(beam: true, fuse: WeaponFuse.Proximity, blast: 30f, range: range, energy: 50f, heat: 5000f, visibility: 100f); - r.Shooter.Target.Value = null; + r.Shooter.SetTarget(TargetRef.None); Aim(r, float2(0, 1)); var weapon = (ConstantWeapon) r.Gun.Weapon; var wears = 0; @@ -543,7 +543,7 @@ public sealed class FireControlPerWeaponTests : IDisposable public void ARefusedBeamNeverStarts(float range, int starts) { var r = Build(beam: true, fuse: WeaponFuse.Proximity, blast: 30f, range: range); - r.Shooter.Target.Value = null; + r.Shooter.SetTarget(TargetRef.None); Aim(r, float2(0, 1)); var weapon = (ConstantWeapon) r.Gun.Weapon; var started = 0; @@ -666,7 +666,7 @@ public sealed class FireControlPerWeaponTests : IDisposable private (int Starts, int Sounds, float Heat, bool Charging) Charge(float range) { var r = Build(charged: true, fuse: WeaponFuse.Proximity, blast: 30f, range: range); - r.Shooter.Target.Value = null; + r.Shooter.SetTarget(TargetRef.None); Aim(r, float2(0, 1)); var weapon = (ChargedWeapon) r.Gun.Weapon; r.Gun.Item.SoundBank = new WwiseMetaSoundBank { IncludedEvents = new[] { new WwiseMetaObject { Id = 7, Name = "gun_charge_play" } } }; @@ -701,7 +701,7 @@ public sealed class FireControlPerWeaponTests : IDisposable public void ATriggeredBurstIsNotReadyBeforeItsFirstRound() { var r = Build(range: 100f); - r.Shooter.Target.Value = null; + r.Shooter.SetTarget(TargetRef.None); Aim(r, float2(0, 1)); var weapon = (InstantWeapon) r.Gun.Weapon; r.Zone.Update(.01f); // resolves the gun's stats, which size the burst @@ -719,7 +719,7 @@ public sealed class FireControlPerWeaponTests : IDisposable public void AChargedWeaponPressedAgainBeforeItsReleasedRoundFliesStartsNoSecondCharge() { var r = Build(charged: true, range: 100f); - r.Shooter.Target.Value = null; + r.Shooter.SetTarget(TargetRef.None); Aim(r, float2(0, 1)); var weapon = (ChargedWeapon) r.Gun.Weapon; var starts = 0; @@ -747,7 +747,7 @@ public sealed class FireControlPerWeaponTests : IDisposable public void AHeldTriggerFiresOncePerCooldownAndARefusedBurstLeavesNone(float range, int shots) { var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range); - r.Shooter.Target.Value = null; + r.Shooter.SetTarget(TargetRef.None); Aim(r, float2(0, 1)); var weapon = (InstantWeapon) r.Gun.Weapon; var fired = 0; @@ -772,7 +772,7 @@ public sealed class FireControlPerWeaponTests : IDisposable public void ABurstTriggeredAfterAFiredOneIsNotReadyBeforeItsFirstRound() { var r = Build(range: 100f); - r.Shooter.Target.Value = null; + r.Shooter.SetTarget(TargetRef.None); Aim(r, float2(0, 1)); var weapon = (InstantWeapon) r.Gun.Weapon; r.Zone.Update(.25f); diff --git a/tests/Aetheria.Shared.Tests/FireControlTests.cs b/tests/Aetheria.Shared.Tests/FireControlTests.cs index 49753d21f..33c90eeba 100644 --- a/tests/Aetheria.Shared.Tests/FireControlTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlTests.cs @@ -205,7 +205,7 @@ public sealed class FireControlTests : IDisposable shooter.Position = float3.zero; target.Position = float3(0, 0, 10); // dead ahead of the None-rotation hardpoint, well within range and arc - shooter.Target.Value = target; + shooter.SetTarget(target); // Cut 3: CombatState now gates on FireControl.HitProbability, which is zero for an undetected target // (the target is not in VisibleEntities) regardless of arc -- cross TargetDetectionInfoThreshold // directly, the same way a Sensor's own info accrual eventually would. diff --git a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs index 31e390abe..4c56b5ff6 100644 --- a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs +++ b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs @@ -245,7 +245,7 @@ public sealed class IffAndCombatTests : IDisposable var target = NewShip(items, zone, new Faction { Name = "Target" }); var weapon = (InstantWeapon) shooter.Equipment.Single(e => e.Behaviors.Any(b => b is Weapon)).Behaviors.Single(b => b is Weapon); - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.SetIff(target, false); // explicitly neutral weapon.Execute(.01f); // resolves the gun's stats: a burst has no rounds before the first tick @@ -263,7 +263,7 @@ public sealed class IffAndCombatTests : IDisposable var target = NewShip(items, zone, new Faction { Name = "Target" }); var weapon = (InstantWeapon) shooter.Equipment.Single(e => e.Behaviors.Any(b => b is Weapon)).Behaviors.Single(b => b is Weapon); - shooter.Target.Value = target; + shooter.SetTarget(target); shooter.SetIff(target, true); weapon.Execute(.01f); // resolves the gun's stats: a burst has no rounds before the first tick @@ -319,7 +319,7 @@ public sealed class IffAndCombatTests : IDisposable }; var gunItem = shooter.Equipment.Single(e => e.Behaviors.Any(b => b is Weapon)); var lockWeapon = new LockWeapon(lockWeaponData, gunItem); - shooter.Target.Value = target; + shooter.SetTarget(target); lockWeapon.Execute(1f); diff --git a/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs new file mode 100644 index 000000000..1467ae316 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs @@ -0,0 +1,262 @@ +/* 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.IO; +using CultMath; +using GameCult.Caching; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; + +// Cut 1 (docs/mining-cut.md): belts stop being simulated every tick on a threaded double buffer and become a +// pure function of zone time, evaluated on request. These tests pin that: a chunk's pose is exactly the +// closed-form orbit at the zone's current time (no staleness), it does not depend on how many ticks it took to +// get there (no stored state), and the size rule moved off the deleted per-tick pass unchanged. Every expected +// value below is computed independently, by the closed form written in this file -- never by calling +// AsteroidBelt.Pose/Size, the code under test. +public sealed class MiningCut1Tests : IDisposable +{ + private int _fixtureCount; + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-miningcut1-" + Guid.NewGuid().ToString("N")); + + public MiningCut1Tests() => Directory.CreateDirectory(_root); + public void Dispose() => Directory.Delete(_root, true); + + private static GameplaySettings GameSettings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp() + }; + + // Authored-shape curves, not defaults: a default ExponentialLerp/ExponentialCurve evaluates to a flat + // constant everywhere, which would hide a broken formula (every asteroid or every damage state would read + // alike). These give period, size and hitpoints real, distinct curvature over the fixture's distance and + // damage range. + private static PlanetSettings Settings() => new PlanetSettings + { + OrbitPeriod = new ExponentialCurve { Multiplier = 0.6f, Exponent = 1.3f, Constant = 40f }, + AsteroidSize = new ExponentialLerp { Minimum = 2f, Maximum = 9f, Exponent = 1.7f }, + AsteroidHitpoints = new ExponentialLerp { Minimum = 10f, Maximum = 300f, Exponent = 2f }, + AsteroidRespawnTime = new ExponentialLerp { Minimum = 5f, Maximum = 60f, Exponent = 1f } + }; + + private const int AsteroidCount = 24; + private const float RootDistance = 4000f; + private const float RootPhase = 0.17f; + + // A belt of AsteroidCount asteroids at distinct distances, phases, size parameters and rotation speeds, + // orbiting a parent (RootDistance/RootPhase) that is itself moving -- so a test that reads the parent's + // motion through ChunkPose is exercising real recursion, not a fixed point. Each call opens its own + // catalog, so two zones built from this fixture never share cache state. + private (Zone zone, CultRecordKey beltKey, Asteroid[] asteroids, PlanetSettings settings) BuildFixture() + { + var dir = Path.Combine(_root, $"cat{_fixtureCount++}"); + Directory.CreateDirectory(dir); + var catalog = Path.Combine(dir, "Aetheria.cc"); + var run = Path.Combine(dir, "run.cc"); + // OrbitData and BodyData (AsteroidBeltData) are run types (AetheriaStores.RunTypes), not catalog types: + // they need a run store to write into. + var cache = AetheriaStores.Open(catalog, run, catalogWritable: true); + var items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { }); + var settings = Settings(); + + var rootOrbit = new OrbitData { Distance = RootDistance, Phase = RootPhase }; + var rootKey = cache.Upsert(rootOrbit).Key; + + var beltOrbit = new OrbitData { Parent = new CultRecordRef(rootKey), Distance = 900f, Phase = 0.42f }; + var beltOrbitKey = cache.Upsert(beltOrbit).Key; + + var asteroids = new Asteroid[AsteroidCount]; + for (var i = 0; i < AsteroidCount; i++) + { + asteroids[i] = new Asteroid + { + Distance = 120f + i * 11.3f, + Phase = frac(i * 0.6180339887f), + Size = frac(i * 0.37f + 0.1f), + RotationSpeed = 0.15f + i * 0.037f + }; + } + + var beltData = new AsteroidBeltData { Orbit = new CultRecordRef(beltOrbitKey), Asteroids = asteroids }; + var beltKey = cache.Upsert(beltData).Key; + + var pack = new ZonePack + { + Orbits = { new CultRecordRef(rootKey), new CultRecordRef(beltOrbitKey) }, + Planets = { new CultRecordRef(beltKey) }, + Radius = 5000f, + Mass = 10000f, + Time = 0 + }; + + var zone = new Zone(items, settings, pack, new GalaxyZone { Name = "MiningCut1", Owner = null }, null); + return (zone, beltKey, asteroids, settings); + } + + // The formula of Zone.GetOrbitPosition (unchanged by this cut), reimplemented here so the expected value + // never calls production orbit code. + private static float2 OrbitPosition(double time, float distance, float phase, PlanetSettings settings, float2 fixedPosition = default) + { + var period = settings.OrbitPeriod.Evaluate(distance); + if (period <= .01f) return fixedPosition; + var wrapped = (float) frac(time / period); + return OrbitData.Evaluate(frac(wrapped + phase)) * distance + fixedPosition; + } + + // The formula of AsteroidBelt.Pose (Zone.cs:269-271 before this cut), reimplemented independently. + private static float4 ExpectedPose(double time, Asteroid asteroid, float2 parentPosition, PlanetSettings settings, float size) + { + var rot = (float) (time * asteroid.RotationSpeed % (PI * 2)); + var pos = OrbitData.Evaluate((float) frac(time / settings.OrbitPeriod.Evaluate(asteroid.Distance) + asteroid.Phase)) + * asteroid.Distance + parentPosition; + return float4(pos.x, pos.y, rot, size); + } + + private static void AssertClose(float expected, float actual, float eps, string what) + { + Assert.True(Math.Abs(expected - actual) <= eps, $"{what}: expected {expected}, got {actual} (eps {eps})"); + } + + [Fact] + public void ChunkPoseIsTheOrbitAtZoneTime() + { + var (zone, beltKey, asteroids, settings) = BuildFixture(); + + // Uneven dt, so a tick-count-based or double-buffered implementation would disagree with a pure + // function of the accumulated time. + var dts = new[] { 0.13f, 0.07f, 0.31f, 0.02f, 0.5f, 0.11f, 0.19f, 0.08f, 0.44f, 0.03f, 0.27f, 0.09f }; + double time = 0; + foreach (var dt in dts) + { + zone.Update(dt); + time += dt; + } + + var parentPosition = OrbitPosition(time, RootDistance, RootPhase, settings); + + for (var i = 0; i < asteroids.Length; i++) + { + var expected = ExpectedPose(time, asteroids[i], parentPosition, settings, settings.AsteroidSize.Evaluate(asteroids[i].Size)); + var actual = zone.ChunkPose(beltKey, i); + AssertClose(expected.x, actual.x, 0.01f, $"asteroid {i} x"); + AssertClose(expected.y, actual.y, 0.01f, $"asteroid {i} y"); + AssertClose(expected.z, actual.z, 0.001f, $"asteroid {i} rotation"); + AssertClose(expected.w, actual.w, 0.001f, $"asteroid {i} size"); + } + } + + // The fixture's rotation speeds are under 1.1 rad/s, so the accumulated ~2 s of the test above never + // reaches one turn and cannot tell a wrapped rotation from an unwrapped one. Forty seconds turns the + // faster asteroids more than once. + [Fact] + public void ChunkRotationWrapsAtOneTurn() + { + var (zone, beltKey, asteroids, settings) = BuildFixture(); + zone.Update(40f); + var parentPosition = OrbitPosition(40.0, RootDistance, RootPhase, settings); + + var wrapped = 0; + for (var i = 0; i < asteroids.Length; i++) + { + if (40.0 * asteroids[i].RotationSpeed > PI * 2) wrapped++; + var expected = ExpectedPose(40.0, asteroids[i], parentPosition, settings, settings.AsteroidSize.Evaluate(asteroids[i].Size)); + var actual = zone.ChunkPose(beltKey, i); + AssertClose(expected.z, actual.z, 0.001f, $"asteroid {i} rotation"); + Assert.InRange(actual.z, 0f, PI * 2); + } + Assert.True(wrapped > 0, "sanity: the fixture must turn at least one asteroid past one full rotation"); + } + + [Fact] + public void ChunkPoseDoesNotDependOnUpdateCadence() + { + var (zoneOneStep, beltKeyA, asteroids, settings) = BuildFixture(); + zoneOneStep.Update(1.0f); + + var (zoneManySteps, beltKeyB, _, _) = BuildFixture(); + for (var i = 0; i < 60; i++) + zoneManySteps.Update(1f / 60f); + + for (var i = 0; i < asteroids.Length; i++) + { + var a = zoneOneStep.ChunkPose(beltKeyA, i); + var b = zoneManySteps.ChunkPose(beltKeyB, i); + // Float summation of 60 steps of 1/60 drifts from 1.0 by a few ULPs; the pose is a pure function + // of that (nearly identical) time, not of how many ticks produced it, so a loose position epsilon + // catches a cadence-dependent implementation without chasing float noise. + AssertClose(a.x, b.x, 0.05f, $"asteroid {i} x"); + AssertClose(a.y, b.y, 0.05f, $"asteroid {i} y"); + AssertClose(a.z, b.z, 0.01f, $"asteroid {i} rotation"); + AssertClose(a.w, b.w, 0.0001f, $"asteroid {i} size"); + } + } + + // Cut 2 (docs/mining-cut.md) moved wear off AsteroidBelt and onto Zone, so this now pins only the two pure + // size formulas AsteroidBelt still owns (undamaged, and damaged-given-a-damage-value). Whether a chunk is + // broken, and what its size reads as when broken, is Zone's decision -- see MiningCut2Tests + // (BrokenChunkHasNoRadius, DamagedChunkShrinksTowardBroken). + [Fact] + public void UndamagedAndDamagedSizeAreThePureCurves() + { + var (zone, beltKey, asteroids, settings) = BuildFixture(); + var belt = zone.AsteroidBelts[beltKey]; + + const int undamagedIndex = 5; + const int damagedIndex = 9; + + var undamagedExpected = settings.AsteroidSize.Evaluate(asteroids[undamagedIndex].Size); + Assert.Equal(undamagedExpected, belt.UndamagedSize(undamagedIndex, settings), 4); + + var hp = settings.AsteroidHitpoints.Evaluate(asteroids[damagedIndex].Size); + var damage = hp * 0.6f; + var damagedExpected = settings.AsteroidSize.Evaluate((hp - damage) / hp * asteroids[damagedIndex].Size); + var damagedActual = belt.DamagedSize(damagedIndex, damage, settings); + Assert.Equal(damagedExpected, damagedActual, 4); + Assert.True(damagedActual < settings.AsteroidSize.Evaluate(asteroids[damagedIndex].Size), + "a damaged asteroid must be smaller than its undamaged size"); + } + + // Stryker gap (2026-09-25 Stryker pass on this cut): nothing called Zone.EvaluateBelt or + // AsteroidBelt.Evaluate, the renderer's own path (ZoneRenderer.cs:452-478), leaving both at NoCoverage. + // This does not re-derive the orbit formula (that is ChunkPoseIsTheOrbitAtZoneTime's job); it pins that the + // batch path is the same function as the per-index one, per index, over every asteroid -- so the renderer's + // buffer can never quietly diverge into a second copy of the pose formula. + [Fact] + public void EvaluateBeltFillsEveryAsteroidExactlyAsChunkPoseWould() + { + var (zone, beltKey, asteroids, _) = BuildFixture(); + zone.Update(0.37f); + + var buffer = new float4[asteroids.Length]; + zone.EvaluateBelt(beltKey, buffer); + + Assert.Equal(asteroids.Length, buffer.Length); + for (var i = 0; i < asteroids.Length; i++) + { + var expected = zone.ChunkPose(beltKey, i); + Assert.Equal(expected.x, buffer[i].x, 4); + Assert.Equal(expected.y, buffer[i].y, 4); + Assert.Equal(expected.z, buffer[i].z, 4); + Assert.Equal(expected.w, buffer[i].w, 4); + } + } + + // Stryker gap: AsteroidBelt.Radius (Zone.cs, AsteroidBelt constructor) had no assertion, so Max survived a + // Min mutation. The renderer's visibility bounds (ZoneRenderer.cs:452) and minimap mesh (:663) both use it + // as the belt's farthest asteroid; a Min there would cull or mis-bound the belt. + [Fact] + public void BeltRadiusIsTheFarthestAsteroidsDistance() + { + var (zone, beltKey, asteroids, _) = BuildFixture(); + + var expectedRadius = float.MinValue; + foreach (var a in asteroids) + if (a.Distance > expectedRadius) expectedRadius = a.Distance; + + Assert.Equal(expectedRadius, zone.AsteroidBelts[beltKey].Radius, 4); + } +} diff --git a/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs new file mode 100644 index 000000000..647406669 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs @@ -0,0 +1,523 @@ +/* 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.Buffers; +using System.Collections.Generic; +using System.IO; +using CultMath; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using Xunit; +using static CultMath.math; + +// Cut 2 (docs/mining-cut.md): the dead per-tick "hit it with a tool" behavior and its per-tick accumulator are +// gone, along with the scanner's dead survey half, and chunk wear gets one owner: Zone. These tests pin that +// owner's behaviour -- accumulation, breaking, lazy respawn, and the persistence round trip through ZonePack's +// new nullable key 6 -- never by reaching into AsteroidBelt (it no longer holds wear at all). +public sealed class MiningCut2Tests : IDisposable +{ + private int _fixtureCount; + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-miningcut2-" + Guid.NewGuid().ToString("N")); + + public MiningCut2Tests() => Directory.CreateDirectory(_root); + public void Dispose() => Directory.Delete(_root, true); + + private static readonly MessagePackSerializerOptions PackOptions = + CultDocumentMessagePackSerialization.OptionsFor(typeof(ZonePack).Assembly); + + private static GameplaySettings GameSettings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp() + }; + + // Authored-shape curves, not flat defaults: hitpoints and respawn time both vary with size over the + // fixture's range, so a test that pins "breaks at the authored hitpoints" or "respawns at the authored + // curve" is exercising real curvature, not a constant that any wrong formula would also satisfy. + private static PlanetSettings Settings() => new PlanetSettings + { + OrbitPeriod = new ExponentialCurve { Multiplier = 0.6f, Exponent = 1.3f, Constant = 40f }, + AsteroidSize = new ExponentialLerp { Minimum = 2f, Maximum = 9f, Exponent = 1.7f }, + AsteroidHitpoints = new ExponentialLerp { Minimum = 20f, Maximum = 220f, Exponent = 1.6f }, + AsteroidRespawnTime = new ExponentialLerp { Minimum = 8f, Maximum = 96f, Exponent = 1.2f }, + // The zone builds a Planet for the fixture's non-belt body; its constructor reads these three. + BodyRadius = new ExponentialCurve(), + GravityRadius = new ExponentialCurve(), + GravityDepth = new ExponentialCurve() + }; + + private sealed class Fixture + { + public Zone Zone; + public ItemManager Items; + public PlanetSettings Settings; + public CultRecordKey BeltA; + public CultRecordKey BeltB; + public CultRecordKey Planet; + public Asteroid[] AsteroidsA; + public Asteroid[] AsteroidsB; + } + + // Two belts of different sizes, asteroids at distinct sizes within each -- so a bug that keys wear by field + // alone (ignoring index), or by index alone (ignoring field), shows up as cross-talk between chunks that + // share an index or a belt. + private Fixture BuildFixture() + { + var dir = Path.Combine(_root, $"cat{_fixtureCount++}"); + Directory.CreateDirectory(dir); + var cache = AetheriaStores.Open(Path.Combine(dir, "Aetheria.cc"), Path.Combine(dir, "run.cc"), catalogWritable: true); + var items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { }); + var settings = Settings(); + + var rootOrbit = new OrbitData { Distance = 3000f, Phase = 0.05f }; + var rootKey = cache.Upsert(rootOrbit).Key; + + CultRecordKey MakeBelt(int count, float baseDistance, float sizeStart) + { + var beltOrbit = new OrbitData { Parent = new CultRecordRef(rootKey), Distance = baseDistance, Phase = 0.11f }; + var beltOrbitKey = cache.Upsert(beltOrbit).Key; + var asteroids = new Asteroid[count]; + for (var i = 0; i < count; i++) + { + asteroids[i] = new Asteroid + { + Distance = 100f + i * 9.7f, + Phase = frac(i * 0.618f), + Size = frac(sizeStart + i * 0.29f), + RotationSpeed = 0.1f + i * 0.02f + }; + } + var beltData = new AsteroidBeltData { Orbit = new CultRecordRef(beltOrbitKey), Asteroids = asteroids }; + return cache.Upsert(beltData).Key; + } + + var beltAKey = MakeBelt(9, 700f, 0.05f); + var beltBKey = MakeBelt(5, 1600f, 0.61f); + // A body that is not a debris field: a chunk of it must not exist, whatever its index. + var planetKey = cache.Upsert(new PlanetData { Orbit = new CultRecordRef(rootKey) }).Key; + + var beltAData = (AsteroidBeltData) cache.Get(beltAKey); + var beltBData = (AsteroidBeltData) cache.Get(beltBKey); + + var pack = new ZonePack + { + Orbits = { new CultRecordRef(rootKey) }, + Planets = { new CultRecordRef(beltAKey), new CultRecordRef(beltBKey), new CultRecordRef(planetKey) }, + Radius = 5000f, + Mass = 10000f, + Time = 0 + }; + // Both belt orbits share the root as parent and are already registered against the cache; the Zone + // constructor discovers each belt's own orbit key through AsteroidBeltData.Orbit, but it also needs + // every orbit key it will walk listed in pack.Orbits. + pack.Orbits.Add(new CultRecordRef(beltAData.Orbit.Key)); + pack.Orbits.Add(new CultRecordRef(beltBData.Orbit.Key)); + + var zone = new Zone(items, settings, pack, new GalaxyZone { Name = "MiningCut2", Owner = null }, null); + return new Fixture + { + Zone = zone, Items = items, Settings = settings, + BeltA = beltAKey, BeltB = beltBKey, Planet = planetKey, + AsteroidsA = beltAData.Asteroids, AsteroidsB = beltBData.Asteroids + }; + } + + [Fact] + public void WearAccumulatesAndDoesNotBreakUntilStrictlyOverHitpoints() + { + var f = BuildFixture(); + const int index = 3; + var chunk = new ChunkId(f.BeltA, index); + var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[index].Size); + + // Several partial hits under the threshold: still there, still shrinking. + Assert.False(f.Zone.Wear(chunk, hp * 0.3f)); + Assert.True(f.Zone.ChunkExists(chunk)); + Assert.False(f.Zone.Wear(chunk, hp * 0.3f)); + Assert.True(f.Zone.ChunkExists(chunk)); + + var before = f.Zone.ChunkRadius(chunk); + Assert.True(before < f.Zone.AsteroidBelts[f.BeltA].UndamagedSize(index, f.Settings), + "accumulated but unbroken damage must have already shrunk the chunk"); + + // The remaining exact amount lands accumulated damage exactly on the hitpoint threshold: pins the + // break condition as strictly greater-than, not greater-or-equal (today's behaviour, kept deliberately + // -- see Zone.Wear's own comment). Equal-to-hitpoints must not break the chunk. + var remaining = hp - hp * 0.6f; + Assert.False(f.Zone.Wear(chunk, remaining)); + Assert.True(f.Zone.ChunkExists(chunk), "damage exactly equal to hitpoints must not break the chunk"); + + // One more point of damage tips it over. + Assert.True(f.Zone.Wear(chunk, 0.01f)); + Assert.False(f.Zone.ChunkExists(chunk)); + Assert.Equal(0f, f.Zone.ChunkRadius(chunk)); + } + + [Fact] + public void ABrokenChunkReturnsAtItsRespawnTimeFullyHealed() + { + var f = BuildFixture(); + const int index = 1; + var chunk = new ChunkId(f.BeltA, index); + var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[index].Size); + var respawn = f.Settings.AsteroidRespawnTime.Evaluate(f.AsteroidsA[index].Size); + + Assert.True(f.Zone.Wear(chunk, hp + 1f)); + Assert.False(f.Zone.ChunkExists(chunk)); + + // Uneven steps that sum to just under the authored respawn time: still broken. + var dts = new float[] { respawn * 0.2f, respawn * 0.31f, respawn * 0.1f, respawn * 0.15f }; + foreach (var dt in dts) f.Zone.Update(dt); + Assert.False(f.Zone.ChunkExists(chunk), "must still be broken just under the authored respawn time"); + + // The remaining sliver plus a margin crosses the respawn time. + f.Zone.Update(respawn * 0.3f); + Assert.True(f.Zone.ChunkExists(chunk), "must have respawned once zone time reaches the authored respawn time"); + Assert.Equal(f.Zone.AsteroidBelts[f.BeltA].UndamagedSize(index, f.Settings), f.Zone.ChunkRadius(chunk), 4); + } + + [Fact] + public void BrokenThenRespawnedThenWornAgainStartsFromZeroDamage() + { + var f = BuildFixture(); + const int index = 2; + var chunk = new ChunkId(f.BeltB, index); + var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsB[index].Size); + var respawn = f.Settings.AsteroidRespawnTime.Evaluate(f.AsteroidsB[index].Size); + + Assert.True(f.Zone.Wear(chunk, hp * 1.5f)); + f.Zone.Update(respawn + 1f); + Assert.True(f.Zone.ChunkExists(chunk)); + + // A hit well under hitpoints, on the freshly respawned chunk, must not break it -- if the old damage + // (or the old broken state) leaked through, this hit alone would already be over any stale threshold. + Assert.False(f.Zone.Wear(chunk, hp * 0.4f)); + Assert.True(f.Zone.ChunkExists(chunk)); + var radius = f.Zone.ChunkRadius(chunk); + Assert.True(radius > 0f && radius < f.Zone.AsteroidBelts[f.BeltB].UndamagedSize(index, f.Settings)); + } + + [Fact] + public void WearIsIndependentPerChunk() + { + var f = BuildFixture(); + // Same index in both belts, and a different index in the same belt, so a key collision on either half + // of ChunkId would show up as cross-talk. + var chunkA0 = new ChunkId(f.BeltA, 0); + var chunkB0 = new ChunkId(f.BeltB, 0); + var chunkA1 = new ChunkId(f.BeltA, 1); + + var hpA0 = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[0].Size); + Assert.True(f.Zone.Wear(chunkA0, hpA0 + 1f)); + + Assert.False(f.Zone.ChunkExists(chunkA0)); + Assert.True(f.Zone.ChunkExists(chunkB0), "a different field at the same index must be unaffected"); + Assert.True(f.Zone.ChunkExists(chunkA1), "a different index in the same field must be unaffected"); + Assert.Equal(f.Zone.AsteroidBelts[f.BeltB].UndamagedSize(0, f.Settings), f.Zone.ChunkRadius(chunkB0), 4); + } + + [Fact] + public void WearSurvivesPackAndUnpack() + { + var f = BuildFixture(); + var damaged = new ChunkId(f.BeltA, 4); + var broken = new ChunkId(f.BeltB, 3); + var untouched = new ChunkId(f.BeltA, 5); + + var hpDamaged = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[4].Size); + var hpBroken = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsB[3].Size); + + Assert.False(f.Zone.Wear(damaged, hpDamaged * 0.45f)); + Assert.True(f.Zone.Wear(broken, hpBroken + 5f)); + f.Zone.Update(1.3f); + + var damagedRadiusBefore = f.Zone.ChunkRadius(damaged); + var pack = f.Zone.PackZone(); + Assert.NotNull(pack.ChunkWear); + Assert.True(pack.ChunkWear.Count >= 2); + + var reloaded = new Zone(f.Items, f.Settings, pack, new GalaxyZone { Name = "MiningCut2Reload", Owner = null }, null); + + Assert.True(reloaded.ChunkExists(untouched)); + Assert.False(reloaded.ChunkExists(broken), "a still-broken chunk must stay broken across save/continue"); + Assert.True(reloaded.ChunkExists(damaged)); + Assert.Equal(damagedRadiusBefore, reloaded.ChunkRadius(damaged), 4); + } + + [Fact] + public void APackOmitsExpiredWear() + { + var f = BuildFixture(); + var chunk = new ChunkId(f.BeltA, 6); + var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[6].Size); + var respawn = f.Settings.AsteroidRespawnTime.Evaluate(f.AsteroidsA[6].Size); + + Assert.True(f.Zone.Wear(chunk, hp + 1f)); + f.Zone.Update(respawn + 2f); + Assert.True(f.Zone.ChunkExists(chunk), "sanity: this chunk must have respawned before packing"); + + var pack = f.Zone.PackZone(); + var stillPacked = pack.ChunkWear?.Exists(w => w.Field.Equals(chunk.Field) && w.Index == chunk.Index) ?? false; + Assert.False(stillPacked, "a fully healed, no-longer-broken entry carries no live information and must be pruned"); + } + + // Persistence rule (docs/mining-cut.md): ZonePack key 6 is nullable so a record from before it existed -- + // built here as raw MessagePack bytes with only the six original keys, not a default-constructed ZonePack + // -- deserializes with ChunkWear == null and loads as a zone with no wear, rather than throwing or reading + // some default non-null instance. + [Fact] + public void ARunWithoutKeySixLoadsWhole() + { + var f = BuildFixture(); + var oldShapePack = new ZonePack + { + Planets = { new CultRecordRef(f.BeltA), new CultRecordRef(f.BeltB) }, + Orbits = f.Zone.Orbits.Keys.ConvertToRefList(), + Entities = new List(), + Radius = 5000f, + Mass = 10000f, + Time = 12.5 + }; + + var buffer = new ArrayBufferWriter(); + var writer = new MessagePackWriter(buffer); + writer.WriteArrayHeader(6); // the pre-Cut-2 shape: keys 0-5 only, no slot for key 6 at all. + MessagePackSerializer.Serialize(ref writer, oldShapePack.Planets, PackOptions); + MessagePackSerializer.Serialize(ref writer, oldShapePack.Orbits, PackOptions); + MessagePackSerializer.Serialize(ref writer, oldShapePack.Entities, PackOptions); + MessagePackSerializer.Serialize(ref writer, oldShapePack.Radius, PackOptions); + MessagePackSerializer.Serialize(ref writer, oldShapePack.Mass, PackOptions); + MessagePackSerializer.Serialize(ref writer, oldShapePack.Time, PackOptions); + writer.Flush(); + + var loadedPack = MessagePackSerializer.Deserialize(buffer.WrittenMemory, PackOptions); + Assert.Null(loadedPack.ChunkWear); + + var zone = new Zone(f.Items, f.Settings, loadedPack, new GalaxyZone { Name = "MiningCut2Old", Owner = null }, null); + Assert.True(zone.ChunkExists(new ChunkId(f.BeltA, 0))); + Assert.Equal(zone.AsteroidBelts[f.BeltA].UndamagedSize(0, f.Settings), zone.ChunkRadius(new ChunkId(f.BeltA, 0)), 4); + } + + // Stryker gap (2026-09-25 pass on this cut): ChunkExists's range check reads `chunk.Index < 0 || + // chunk.Index >= length`; nothing exercised either side of that OR alone, or the >= boundary at exactly + // `length`, so a mutant weakening it to AND (which can never be true, since an index cannot be both + // negative and past the end at once) and a mutant weakening >= to > both survived. + [Fact] + public void OutOfRangeChunkIndicesDoNotExist() + { + var f = BuildFixture(); + Assert.False(f.Zone.ChunkExists(new ChunkId(f.BeltA, -1)), "a negative index alone must already fail"); + Assert.False(f.Zone.ChunkExists(new ChunkId(f.BeltA, f.AsteroidsA.Length)), + "the index exactly at the asteroid count is already past the end (0-based), not the first one out"); + } + + // Stryker gap: ChunkId.Equals was reachable only through the wear dictionary, whose GetHashCode already + // separates a same-field-different-index or same-index-different-field pair into different buckets, so a + // mutant weakening Equals from `&&` to `||` never got exercised by a lookup. This tests the value equality + // directly, the way the operator's ruling actually describes chunk identity: same field AND same index. + // Bug (found dormant, confirmed by Soul, on Cut 4's path): Wear did not check whether the chunk was already + // broken. A later hit computed newDamage from a fresh 0f (ChunkWear's default Damage after breaking), stayed + // under hitpoints, and wrote BrokenUntil = null -- un-breaking a chunk mid-respawn. The rule: damage to a + // currently-broken chunk (zone time < BrokenUntil) changes nothing. It stays broken until its original + // respawn time regardless of whether the re-hit is above or below hitpoints, and its wear starts from zero + // only once it actually respawns. + [Fact] + public void HittingABrokenChunkDoesNotUnbreakItEitherBelowOrAboveHitpoints() + { + var f = BuildFixture(); + const int index = 4; + var chunk = new ChunkId(f.BeltA, index); + var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[index].Size); + var respawn = f.Settings.AsteroidRespawnTime.Evaluate(f.AsteroidsA[index].Size); + + Assert.True(f.Zone.Wear(chunk, hp + 1f)); + Assert.False(f.Zone.ChunkExists(chunk)); + + // A hit well under hitpoints, inside the respawn window: must not break it (it is already broken) and + // must not touch BrokenUntil. + f.Zone.Update(respawn * 0.2f); + Assert.False(f.Zone.Wear(chunk, hp * 0.1f), "a hit on an already-broken chunk did not break it"); + Assert.False(f.Zone.ChunkExists(chunk), "must still be absent -- the old bug un-broke it here"); + Assert.Equal(0f, f.Zone.ChunkRadius(chunk)); + + // A second hit, this one alone well over hitpoints, inside the same window: still must not break it + // (it is already broken) and must not push BrokenUntil further out. + f.Zone.Update(respawn * 0.3f); + Assert.False(f.Zone.Wear(chunk, hp + 50f), "a hit on an already-broken chunk did not break it, even when large"); + Assert.False(f.Zone.ChunkExists(chunk)); + + // Just under the original respawn time (measured from the original break, not from either re-hit): + // still broken. + f.Zone.Update(respawn * 0.45f); + Assert.False(f.Zone.ChunkExists(chunk), "must still be broken just under the ORIGINAL respawn time"); + + // Crossing the original respawn time: present again, and unworn -- the re-hits left no residue. + f.Zone.Update(respawn * 0.1f); + Assert.True(f.Zone.ChunkExists(chunk), "must respawn at its original time despite the re-hits"); + Assert.Equal(f.Zone.AsteroidBelts[f.BeltA].UndamagedSize(index, f.Settings), f.Zone.ChunkRadius(chunk), 4); + + // Worn again from fresh: breaks on the normal threshold, not one warped by the earlier no-op hits. + Assert.False(f.Zone.Wear(chunk, hp * 0.9f)); + Assert.True(f.Zone.ChunkExists(chunk)); + Assert.True(f.Zone.Wear(chunk, hp * 0.2f)); + Assert.False(f.Zone.ChunkExists(chunk)); + } + + // Same rule, checked across a save/reload: a re-hit taken while a chunk is broken must not move BrokenUntil, + // and that must survive the pack/unpack round trip -- not just live in the same Zone instance. + [Fact] + public void ReHitDuringRespawnSurvivesSaveRoundTripAtTheSameAbsoluteTime() + { + var f = BuildFixture(); + const int idx = 3; + var chunk = new ChunkId(f.BeltB, idx); + var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsB[idx].Size); + var respawn = f.Settings.AsteroidRespawnTime.Evaluate(f.AsteroidsB[idx].Size); + + Assert.True(f.Zone.Wear(chunk, hp + 1f)); + f.Zone.Update(respawn * 0.4f); + Assert.False(f.Zone.Wear(chunk, hp * 0.5f), "re-hit inside the window did not break an already-broken chunk"); + + var pack = f.Zone.PackZone(); + var reloaded = new Zone(f.Items, f.Settings, pack, new GalaxyZone { Name = "MiningCut2ReHitReload", Owner = null }, null); + + Assert.False(reloaded.ChunkExists(chunk), "still broken immediately after reload"); + + // Just under the original respawn time (measured from the original break at zone time 0): still broken. + reloaded.Update(respawn * 0.59f); + Assert.False(reloaded.ChunkExists(chunk), "must still be broken just under the ORIGINAL respawn time after reload"); + + reloaded.Update(respawn * 0.02f); + Assert.True(reloaded.ChunkExists(chunk), "must respawn at its original absolute time after reload, unmoved by the re-hit"); + Assert.Equal(f.Zone.AsteroidBelts[f.BeltB].UndamagedSize(idx, f.Settings), reloaded.ChunkRadius(chunk), 4); + } + + [Fact] + public void ChunkIdEqualityRequiresBothFieldAndIndex() + { + var f = BuildFixture(); + var a0 = new ChunkId(f.BeltA, 0); + var a0Again = new ChunkId(f.BeltA, 0); + var a1 = new ChunkId(f.BeltA, 1); + var b0 = new ChunkId(f.BeltB, 0); + + Assert.Equal(a0, a0Again); + Assert.NotEqual(a0, a1); + Assert.NotEqual(a0, b0); + } + + // Stryker gap: the broken-until comparisons (ChunkExists, ChunkRadius) and the pack-time pruning rule + // (IsExpired) all compare BrokenUntil against zone time at their own boundary, and no existing test landed + // exactly on it (ABrokenChunkReturnsAtItsRespawnTimeFullyHealed and APackOmitsExpiredWear both advance well + // past it). Breaking at time 0 and stepping by exactly the authored respawn time lands zone time on + // BrokenUntil bit-for-bit (0.0 + respawnTime, then 0.0 += respawnTime): the chunk must already read as + // respawned at that exact instant, not one tick later, and packing at that instant must already prune it. + [Fact] + public void RespawnAndPruneBoundaryIsInclusiveOfExactlyNow() + { + var f = BuildFixture(); + const int index = 7; + var chunk = new ChunkId(f.BeltA, index); + var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[index].Size); + var respawn = f.Settings.AsteroidRespawnTime.Evaluate(f.AsteroidsA[index].Size); + + Assert.True(f.Zone.Wear(chunk, hp + 1f)); + f.Zone.Update(respawn); // zone time now equals BrokenUntil exactly, not "respawn plus a margin". + + Assert.True(f.Zone.ChunkExists(chunk), "must read as respawned the instant zone time reaches BrokenUntil, not strictly after it"); + Assert.Equal(f.Zone.AsteroidBelts[f.BeltA].UndamagedSize(index, f.Settings), f.Zone.ChunkRadius(chunk), 4); + + var pack = f.Zone.PackZone(); + var stillPacked = pack.ChunkWear?.Exists(w => w.Field.Equals(chunk.Field) && w.Index == chunk.Index) ?? false; + Assert.False(stillPacked, "at exactly the respawn instant the entry already carries no live information"); + } + + // Soul-debt pass (mining-cut-refresh A.1 item 11): the rules below were unpinned when every mutant of the + // chunk surface was run against the Cut 1-2 tests. Each fails under its own mutation. + [Fact] + public void OnlyADebrisFieldHasChunks() + { + var f = BuildFixture(); + Assert.True(f.Zone.Planets.ContainsKey(f.Planet), "sanity: the fixture's planet is a body of the zone"); + Assert.False(f.Zone.ChunkExists(new ChunkId(f.Planet, 0)), "a planet is a body, not a field of chunks"); + Assert.False(f.Zone.ChunkExists(new ChunkId(new CultRecordKey(Guid.NewGuid().ToString("N")), 0)), "a key that names no body has no chunks"); + } + + // The respawn instant belongs to the live chunk on every reader: ChunkExists and ChunkRadius already say so + // (RespawnAndPruneBoundaryIsInclusiveOfExactlyNow); Wear, the writer, must agree, or a hit landing on the + // instant is swallowed by a chunk every reader shows. + [Fact] + public void AHitOnTheExactRespawnInstantLandsOnTheLiveChunk() + { + var f = BuildFixture(); + const int index = 2; + var chunk = new ChunkId(f.BeltA, index); + var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[index].Size); + var respawn = f.Settings.AsteroidRespawnTime.Evaluate(f.AsteroidsA[index].Size); + + Assert.True(f.Zone.Wear(chunk, hp + 1f)); + f.Zone.Update(respawn); + Assert.True(f.Zone.ChunkExists(chunk)); + + Assert.True(f.Zone.Wear(chunk, hp + 1f), "a hit on the instant of respawn breaks the respawned chunk again"); + Assert.False(f.Zone.ChunkExists(chunk)); + } + + // A chunk that has respawned and been worn again is damaged and not broken. Its saved wear says so: no + // respawn time is carried over from the break it already recovered from. + [Fact] + public void WearAfterRespawnPacksWithoutTheOldRespawnTime() + { + var f = BuildFixture(); + const int index = 1; + var chunk = new ChunkId(f.BeltA, index); + var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[index].Size); + var respawn = f.Settings.AsteroidRespawnTime.Evaluate(f.AsteroidsA[index].Size); + + Assert.True(f.Zone.Wear(chunk, hp + 1f)); + f.Zone.Update(respawn + 1f); + Assert.False(f.Zone.Wear(chunk, hp * 0.3f)); + + var packed = f.Zone.PackZone().ChunkWear.Find(w => w.Field.Equals(chunk.Field) && w.Index == index); + Assert.NotNull(packed); + Assert.Equal(hp * 0.3f, packed.Damage, 4); + Assert.Null(packed.BrokenUntil); + } + + // ChunkPose is the sim's read of a chunk's size as well as its place: worn chunks are smaller, broken ones + // have none. Reading the undamaged size here would show a shot-out rock at full size to everything that + // asks through ChunkPose. + [Fact] + public void ChunkPoseCarriesTheChunksWornSize() + { + var f = BuildFixture(); + const int index = 3; + var chunk = new ChunkId(f.BeltA, index); + var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[index].Size); + + var undamaged = f.Zone.ChunkPose(f.BeltA, index).w; + Assert.Equal(f.Zone.AsteroidBelts[f.BeltA].UndamagedSize(index, f.Settings), undamaged, 4); + + Assert.False(f.Zone.Wear(chunk, hp * 0.6f)); + var worn = f.Zone.ChunkPose(f.BeltA, index).w; + Assert.Equal(f.Zone.ChunkRadius(chunk), worn, 4); + Assert.True(worn < undamaged, "a worn chunk is smaller than an untouched one"); + + Assert.True(f.Zone.Wear(chunk, hp)); + Assert.Equal(0f, f.Zone.ChunkPose(f.BeltA, index).w); + } +} + +internal static class MiningCut2TestExtensions +{ + public static List> ConvertToRefList(this IEnumerable keys) + { + var list = new List>(); + foreach (var key in keys) list.Add(new CultRecordRef(key)); + return list; + } +} diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs new file mode 100644 index 000000000..1170ade7a --- /dev/null +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -0,0 +1,1022 @@ +/* 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.IO; +using System.Linq; +using System.Security.Cryptography; +using CultMath; +using GameCult.Caching; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; + +// Cut 3 (docs/mining-cut-refresh.md): a chunk is a target, detected by reflected light. One detection gain rule +// (Sensor.Gain) serves both the per-tick entity loop and the on-demand chunk query, and the chunk query returns +// the value the per-tick rule settles at (Q14 A). A belt's field kind is chosen once and saved (Q16 B). +public sealed partial class MiningCut3Tests : IDisposable +{ + private const float Dt = .05f; + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-miningcut3-" + Guid.NewGuid().ToString("N")); + private readonly List _caches = new List(); + private int _fixtureCount; + + public MiningCut3Tests() => Directory.CreateDirectory(_root); + + public void Dispose() + { + foreach (var cache in _caches) cache.Dispose(); + Directory.Delete(_root, true); + } + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + private static GameplaySettings GameSettings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + // Settings.asset's own values for the three detection tunables. + VisibilityDecay = .5f, + TargetInfoDecay = .5f, + TargetDetectionInfoThreshold = .1f, + SchematicCellSize = 2f, + FiringArc = 90f + }; + + // Flat terrain (no zone depth, no gravity wells), one sun whose light reaches 1000 units, and the chunk + // curves MiningCut2Tests uses, so sizes and wear vary over the fixture's range. + private static PlanetSettings PlanetSettings(float lightRadius = 1000f) => new PlanetSettings + { + OrbitPeriod = new ExponentialCurve { Multiplier = 0.6f, Exponent = 1.3f, Constant = 40f }, + AsteroidSize = new ExponentialLerp { Minimum = 2f, Maximum = 9f, Exponent = 1.7f }, + AsteroidHitpoints = new ExponentialLerp { Minimum = 20f, Maximum = 220f, Exponent = 1.6f }, + AsteroidRespawnTime = new ExponentialLerp { Minimum = 8f, Maximum = 96f, Exponent = 1.2f }, + BodyRadius = new ExponentialCurve(), + GravityRadius = new ExponentialCurve(), + GravityDepth = new ExponentialCurve(), + WaveDepth = new ExponentialCurve(), + WaveRadius = new ExponentialCurve(), + WaveSpeed = new ExponentialCurve(), + WaveFrequency = new ExponentialCurve(), + LightRadius = new ExponentialCurve { Constant = lightRadius } + }; + + // A sensitivity that falls off away from the sensor's facing, so the angle term is exercised, not a constant. + private static BezierCurve Falloff() => new BezierCurve + { + Keys = new[] { new float4(0, 1, 0, 0), new float4(1, .3f, 0, 0) } + }; + + private sealed class Scene + { + public CultCache Cache; + public ItemManager Items; + public Zone Zone; + public HullData Hull; + public GearData Eye; + public GearData DimEye; + public ZonePack Pack; + public List Belts = new List(); + public List Kinds = new List(); + } + + // A belt around the sun: its chunks orbit the origin at their own distances, so at zone time 0 a chunk of + // phase p sits at distance × (cos 2πp, sin 2πp). `kind` null leaves the belt kindless, for the zone to assign. + private sealed class BeltSpec + { + public Asteroid[] Asteroids; + public FieldKindData Kind; + } + + private static Asteroid Rock(float distance, float phase = 0f, float size = .5f) => + new Asteroid { Distance = distance, Phase = phase, Size = size, RotationSpeed = .1f }; + + private Scene BuildScene(FieldKindData[] kinds = null, params BeltSpec[] belts) => BuildSceneAt(float2.zero, kinds, belts); + + // The same scene with its sun, and so every belt centre, at `sunAt`. + private Scene BuildSceneAt(float2 sunAt, FieldKindData[] kinds, params BeltSpec[] belts) => + BuildSceneAt(sunAt, 1000f, 0, null, kinds, belts); + + // The same, with the suns' light radius, the zone's starting time and more suns (fixed, belt-less) chosen. + private Scene BuildSceneAt(float2 sunAt, float lightRadius, double time, float2[] moreSuns, FieldKindData[] kinds, params BeltSpec[] belts) + { + var dir = Path.Combine(_root, $"scene{_fixtureCount++}"); + Directory.CreateDirectory(dir); + var cache = AetheriaStores.Open(Path.Combine(dir, "Aetheria.cc"), Path.Combine(dir, "run.cc"), catalogWritable: true); + _caches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + var hullShape = new Shape(5, 5); + foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; + var hull = new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 10, Mass = 1000 }; + cache.Upsert(hull); + var eye = Gear("Eye", new SensorData + { + Sensitivity = Constant(3f), + SensitivityCurve = Falloff(), + PingBoost = Constant(.02f), + PingEnergy = Constant(0f), + PingVisibility = Constant(5f), + PingRange = Constant(300f), + PingCooldown = Constant(4f) + }); + cache.Upsert(eye); + // A far weaker sensor, for scenes of ships: a hull's own heat makes it hundreds of times brighter than a rock, + // and the ordinary sensor would saturate on it at once. + var dimEye = Gear("Dim eye", new SensorData + { + Sensitivity = Constant(.01f), + SensitivityCurve = Falloff(), + PingBoost = Constant(.02f), + PingEnergy = Constant(0f), + PingVisibility = Constant(5f), + PingRange = Constant(300f), + PingCooldown = Constant(4f) + }); + cache.Upsert(dimEye); + foreach (var kind in kinds ?? new FieldKindData[0]) cache.Upsert(kind); + var items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { }); + + var sunOrbit = cache.Upsert(new OrbitData { FixedPosition = sunAt }).Key; + var sun = cache.Upsert(new SunData { Orbit = new CultRecordRef(sunOrbit) }).Key; + var pack = new ZonePack + { + Orbits = { new CultRecordRef(sunOrbit) }, + Planets = { new CultRecordRef(sun) }, + Radius = 5000f, + Mass = 10000f, + Time = time + }; + foreach (var at in moreSuns ?? new float2[0]) + { + var orbit = cache.Upsert(new OrbitData { FixedPosition = at }).Key; + pack.Orbits.Add(new CultRecordRef(orbit)); + pack.Planets.Add(new CultRecordRef(cache.Upsert(new SunData { Orbit = new CultRecordRef(orbit) }).Key)); + } + var scene = new Scene { Cache = cache, Items = items, Hull = hull, Eye = eye, DimEye = dimEye, Pack = pack }; + scene.Kinds.AddRange(kinds ?? new FieldKindData[0]); + foreach (var spec in belts) + { + var beltOrbit = cache.Upsert(new OrbitData { Parent = new CultRecordRef(sunOrbit), Distance = 150f }).Key; + var belt = new AsteroidBeltData { Orbit = new CultRecordRef(beltOrbit), Asteroids = spec.Asteroids }; + if (spec.Kind != null) belt.Kind = cache.RefOf(spec.Kind); + var key = cache.Upsert(belt).Key; + pack.Orbits.Add(new CultRecordRef(beltOrbit)); + pack.Planets.Add(new CultRecordRef(key)); + scene.Belts.Add(key); + } + scene.Zone = new Zone(items, PlanetSettings(lightRadius), pack, new GalaxyZone { Name = "MiningCut3", Owner = null }, null); + return scene; + } + + private static FieldKindData Kind(string name, float crossSection, float weight = 1f) => + new FieldKindData { Name = name, CrossSection = crossSection, GenerationWeight = weight }; + + private static BeltSpec Belt(FieldKindData kind, params Asteroid[] asteroids) => new BeltSpec { Kind = kind, Asteroids = asteroids }; + + // A chunk's planar position now (zone time never advances in these tests). + private static float2 At(Scene s, ChunkId chunk) => s.Zone.ChunkPose(chunk.Field, chunk.Index).xy; + + // The rule as the map states it: kind cross-section × area in schematic cells (cell 2) × the light on it. + private static float ExpectedVisibility(Scene s, ChunkId chunk, FieldKindData kind) + { + var cells = s.Zone.ChunkRadius(chunk) / 2f; + return kind.CrossSection * PI * cells * cells * s.Zone.GetLight(At(s, chunk)); + } + + private static GearData Gear(string name, BehaviorData behavior) => new GearData + { + Name = name, Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { behavior } + }; + + private static EquippableItem Mint(Scene s, ItemData design) => new EquippableItem + { + Data = s.Cache.RefOf(design), + Durability = design is EquippableItemData equippable ? equippable.Durability : 0, + Lot = s.Items.Lots.Add(new Lot { Design = s.Cache.RefOf(design), Origin = new Attributed(), Quality = .5f, Roles = new List() }) + }; + + // A ship held at a planar position, facing +z. `sensor` fits the scene's sensor; `crossSection` fits a + // reflector of that cross-section (its own design, so ships in one scene can differ); each of `weaponRanges` + // fits a gun of that range. + private Ship SpawnShip(Scene s, float2 at, bool sensor = false, float crossSection = 0f, bool secondSensor = false, bool dimSensor = false, params float[] weaponRanges) + { + var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings()); + if (sensor) Assert.True(ship.TryEquip(Mint(s, s.Eye))); + if (secondSensor) Assert.True(ship.TryEquip(Mint(s, s.Eye))); + if (dimSensor) Assert.True(ship.TryEquip(Mint(s, s.DimEye))); + if (crossSection > 0f) + { + var mirror = Gear($"Mirror {crossSection}", new ReflectorData { CrossSection = Constant(crossSection) }); + s.Cache.Upsert(mirror); + Assert.True(ship.TryEquip(Mint(s, mirror))); + } + foreach (var range in weaponRanges) + { + var gun = Gear($"Gun {range}", new InstantWeaponData { Range = Constant(range) }); + s.Cache.Upsert(gun); + Assert.True(ship.TryEquip(Mint(s, gun))); + } + s.Zone.Entities.Add(ship); + Hold(ship, at); + ship.LookDirection = float3(0, 0, 1); + ship.Activate(); + return ship; + } + + private static void Hold(Entity entity, float2 at) + { + entity.Position = float3(at.x, entity.Position.y, at.y); + entity.Velocity = float2.zero; + entity.Direction = float2(0, 1); + } + + // One simulation tick for the given entities, each held in place, in order. Zone time does not advance, so + // chunk poses and sun positions stay where they are. + private static void Tick(params (Entity entity, float2 at)[] held) + { + foreach (var (entity, at) in held) + { + Hold(entity, at); + entity.Update(Dt); + } + } + + private static uint Bits(float value) => BitConverter.ToUInt32(BitConverter.GetBytes(value), 0); + + // The per-tick entity detection loop is unchanged by the extraction of its gain rule: a fixed scene (two ships + // at different ranges and bearings, seen by a weak sensor so neither saturates, a ping part way through) + // yields the same EntityInfoGathered trace, bit for bit, as it did before Sensor.Gain existed. The golden hash + // was recorded against the pre-extraction Sensor.Execute (cb4a1dbd with this scene patched in). + private const string Golden = "919B7EE7C5D1B40F774EE34283D62DE016862FE799B3EF16362D7FC75FD73C79"; + + [Fact] + public void SensorGainExtractionChangesNothing() + { + var s = BuildScene(); + var eyeAt = float2(0, 50); + var nearAt = float2(30, 120); + var farAt = float2(-150, 90); + var observer = SpawnShip(s, eyeAt, dimSensor: true); + var near = SpawnShip(s, nearAt, crossSection: .2f); + var far = SpawnShip(s, farAt, crossSection: 9f); + + var trace = new List(); + var nearTrace = new List(); + for (var tick = 0; tick < 60; tick++) + { + if (tick == 5) observer.Sensor.Ping(); + Tick((near, nearAt), (far, farAt), (observer, eyeAt)); + trace.AddRange(BitConverter.GetBytes(Bits(observer.EntityInfoGathered[near]))); + trace.AddRange(BitConverter.GetBytes(Bits(observer.EntityInfoGathered[far]))); + nearTrace.Add(observer.EntityInfoGathered[near]); + } + + // Not a degenerate scene: both targets settle well short of saturation, and the ping visibly lifts the + // near one above where it settles, so the trace carries the passive rule, the bearing and the ping. + Assert.InRange(observer.EntityInfoGathered[near], .05f, .8f); + Assert.InRange(observer.EntityInfoGathered[far], .05f, .8f); + Assert.True(nearTrace.Max() > observer.EntityInfoGathered[near] + .1f, "the ping left no mark on the trace"); + var hash = Convert.ToHexString(SHA256.HashData(trace.ToArray())); + Assert.True(hash == Golden, $"the EntityInfoGathered trace changed: hash {hash}"); + } + + // Q14 A's parity: one rule, two integrators. A ship carrying a reflector of the chunk's own cross-section + // (the kind's per-cell reflectivity × the chunk's area in cells), held still where the chunk is, settles under + // the per-tick loop at the value ChunkInfo returns on demand. The discrete loop's fixed point sits one decay + // step below the continuous one, exactly: x* (1 - k dt). The observer is off the sensor's axis, so the + // bearing term is live. + [Fact] + public void ChunkInfoMatchesAnEntityHeldStill() + { + var kind = Kind("Asteroid", 1f); + var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f))); + var chunk = new ChunkId(s.Belts[0], 0); + var chunkAt = At(s, chunk); + var eyeAt = float2(50, -150); + var observer = SpawnShip(s, eyeAt, sensor: true); + var cells = s.Zone.ChunkRadius(chunk) / 2f; + var rock = SpawnShip(s, chunkAt, crossSection: kind.CrossSection * PI * cells * cells); + + for (var tick = 0; tick < 600; tick++) Tick((rock, chunkAt), (observer, eyeAt)); + + var settled = observer.ChunkInfo(chunk); + var perTick = observer.EntityInfoGathered[rock]; + Assert.InRange(settled, .12f, .9f); // visible, unsaturated: the balance, not the cap, decides it + var discrete = settled * (1f - GameSettings().TargetInfoDecay * Dt); + Assert.True(abs(perTick - discrete) <= 1e-3f * settled, + $"per-tick loop settled at {perTick}, the on-demand value implies {discrete} (settled {settled})"); + } + + // A chunk outside every sun's light is dark: no visibility, no info, and it cannot be picked, while a lit + // chunk of the same belt and kind can. + [Fact] + public void AChunkInDarknessIsNotVisible() + { + var kind = Kind("Asteroid", 4f); + var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f), Rock(700f, .5f))); + var lit = new ChunkId(s.Belts[0], 0); + var dark = new ChunkId(s.Belts[0], 1); + Assert.Equal(0f, s.Zone.GetLight(At(s, dark))); + + var inTheDark = SpawnShip(s, At(s, dark) + float2(0, -30), sensor: true, weaponRanges: 300f); + Tick((inTheDark, At(s, dark) + float2(0, -30))); + Assert.Equal(0f, s.Zone.ChunkVisibility(dark)); + Assert.Equal(0f, inTheDark.ChunkInfo(dark)); + Assert.False(inTheDark.SetTarget(dark)); + Assert.True(inTheDark.Target.Value.IsNone); + var reachable = new List(); + inTheDark.VisibleChunksInReach(reachable); + Assert.DoesNotContain(dark, reachable); + + var inTheLight = SpawnShip(s, At(s, lit) + float2(0, -30), sensor: true); + Tick((inTheLight, At(s, lit) + float2(0, -30))); // equipment comes online on its first tick + Assert.True(inTheLight.SetTarget(lit)); + Assert.True(inTheLight.Target.Value.Equals(new TargetRef(lit))); + } + + // Visibility is the kind's reflectivity × the chunk's area in cells × the light on it: linear in + // cross-section, quadratic in radius, linear in light. Info follows visibility, so a brighter chunk is seen + // further off. + [Fact] + public void ABrighterOrBiggerChunkIsSeenFurther() + { + var dim = Kind("Dim", 1f); + var bright = Kind("Bright", 2f); + var s = BuildScene(new[] { dim, bright }, + Belt(dim, Rock(150f, 0f, .2f), Rock(150f, .25f, .8f), Rock(250f, .5f, .2f)), + Belt(bright, Rock(150f, 0f, .2f))); + var small = new ChunkId(s.Belts[0], 0); + var big = new ChunkId(s.Belts[0], 1); + var far = new ChunkId(s.Belts[0], 2); + var brightTwin = new ChunkId(s.Belts[1], 0); + + foreach (var (chunk, kind) in new[] { (small, dim), (big, dim), (far, dim), (brightTwin, bright) }) + Assert.Equal(ExpectedVisibility(s, chunk, kind), s.Zone.ChunkVisibility(chunk), 4); + + var vSmall = s.Zone.ChunkVisibility(small); + Assert.Equal(2f, s.Zone.ChunkVisibility(brightTwin) / vSmall, 4); + var radiusRatio = s.Zone.ChunkRadius(big) / s.Zone.ChunkRadius(small); + Assert.True(radiusRatio > 1.5f); + Assert.Equal(radiusRatio * radiusRatio, s.Zone.ChunkVisibility(big) / vSmall, 3); + var lightRatio = s.Zone.GetLight(At(s, far)) / s.Zone.GetLight(At(s, small)); + Assert.InRange(lightRatio, .01f, .9f); + Assert.Equal(lightRatio, s.Zone.ChunkVisibility(far) / vSmall, 3); + + // Seen further: from 180 away the dim twin is below the detection threshold and the bright one above it. + var eyeAt = At(s, small) + float2(0, -180); + var observer = SpawnShip(s, eyeAt, sensor: true); + Tick((observer, eyeAt)); + Assert.Equal(2f, observer.ChunkInfo(brightTwin) / observer.ChunkInfo(small), 3); + Assert.False(observer.ChunkVisible(small)); + Assert.True(observer.ChunkVisible(brightTwin)); + } + + // A worn chunk shrinks, so it dims by exactly its smaller area; a broken chunk has no size and is dark. + [Fact] + public void AWornChunkDims() + { + var kind = Kind("Asteroid", 4f); + var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f))); + var chunk = new ChunkId(s.Belts[0], 0); + var whole = s.Zone.ChunkVisibility(chunk); + var hitpoints = PlanetSettings().AsteroidHitpoints.Evaluate(.5f); + + Assert.False(s.Zone.Wear(chunk, hitpoints * .5f)); + var worn = s.Zone.ChunkVisibility(chunk); + Assert.InRange(worn, whole * .05f, whole * .95f); + Assert.Equal(ExpectedVisibility(s, chunk, kind), worn, 4); + } + + [Fact] + public void ABrokenChunkIsInvisible() + { + var kind = Kind("Asteroid", 4f); + var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f))); + var chunk = new ChunkId(s.Belts[0], 0); + var eyeAt = At(s, chunk) + float2(0, -100); + var observer = SpawnShip(s, eyeAt, sensor: true); + Tick((observer, eyeAt)); + Assert.True(observer.ChunkVisible(chunk)); + + Assert.True(s.Zone.Wear(chunk, PlanetSettings().AsteroidHitpoints.Evaluate(.5f) * 2f)); + Assert.Equal(0f, s.Zone.ChunkVisibility(chunk)); + Assert.Equal(0f, observer.ChunkInfo(chunk)); + Assert.False(observer.SetTarget(chunk)); + } + + // Q13 A: the reach for offering a chunk is the longest range among the entity's ACTIVE weapons. Two visible + // chunks, 200 and about 300 away; guns of 250, 120 and 350 fitted in that order, the last switched off -- so the + // last active gun is not the longest one. + [Fact] + public void CyclingReachIsTheLongestActiveWeaponRange() + { + var kind = Kind("Asteroid", 4f); + var s = BuildScene(new[] { kind }, + Belt(kind, Rock(150f), Rock(length(float2(150, 100)), atan2(100f, 150f) / (2 * PI)))); + var near = new ChunkId(s.Belts[0], 0); + var farther = new ChunkId(s.Belts[0], 1); + var eyeAt = float2(150, -200); + var observer = SpawnShip(s, eyeAt, sensor: true, weaponRanges: new[] { 250f, 120f, 350f }); + Tick((observer, eyeAt)); // weapons read their ranges when they run + Assert.Equal(200f, length(At(s, near) - eyeAt), 2); + Assert.Equal(300f, length(At(s, farther) - eyeAt), 2); + Assert.True(observer.ChunkVisible(near)); + Assert.True(observer.ChunkVisible(farther)); + + EquippedItem Gun(float range) => observer.Equipment.Single(item => item.Data.Name == $"Gun {range}"); + var offered = new List(); + + Gun(350f).Enabled.Value = false; + observer.VisibleChunksInReach(offered); + Assert.Equal(new[] { near }, offered); + + Gun(250f).Enabled.Value = false; + observer.VisibleChunksInReach(offered); + Assert.Empty(offered); + + Gun(350f).Enabled.Value = true; + observer.VisibleChunksInReach(offered); + Assert.Equal(2, offered.Count); + Assert.Contains(near, offered); + Assert.Contains(farther, offered); + } + + // The chunk query returns exactly the chunks within range, from inside the belt's hole, from within the + // annulus and from outside it -- the belt skip never drops a chunk the brute-force answer would keep -- and it + // leaves out a broken chunk. + [Fact] + public void TheChunkQueryFindsExactlyTheChunksInRange() + { + var kind = Kind("Asteroid", 4f); + var rocks = Enumerable.Range(0, 12).Select(i => Rock(200f + i * 15f, i / 12f)).ToArray(); + var centre = float2(400, -250); // off the origin, so a query that ignored the belt centre would disagree + var s = BuildSceneAt(centre, new[] { kind }, Belt(kind, rocks)); + var belt = s.Belts[0]; + Assert.True(s.Zone.Wear(new ChunkId(belt, 5), 1e6f)); // broken + + var found = new List(); + var sawSome = false; + foreach (var (from, range) in new[] + { + (centre + float2(0, 0), 150f), (centre + float2(0, 0), 230f), (centre + float2(0, 0), 400f), + (centre + float2(220, 0), 40f), (centre + float2(-300, 50), 120f), (centre + float2(600, 0), 360f), (centre + float2(600, 0), 200f) + }) + { + ChunksWithin(s, from, range, found); + var expected = Enumerable.Range(0, rocks.Length) + .Where(i => i != 5 && length(At(s, new ChunkId(belt, i)) - from) <= range) + .Select(i => new ChunkId(belt, i)).ToArray(); + Assert.Equal(expected.ToHashSet(), found.ToHashSet()); + Assert.Equal(expected.Length, found.Count); + sawSome |= expected.Length > 0; + } + Assert.True(sawSome); + } + + // The negative for Activate's "still tracked" reconciliation (Entity.Activate) and the per-tick lost-track + // check: a visible chunk target survives ticks and a dock/undock, and reads its range through the chunk. + [Fact] + public void AChunkTargetSurvivesTheInfoPassWhileSeen() + { + var kind = Kind("Asteroid", 4f); + var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f))); + var chunk = new ChunkId(s.Belts[0], 0); + var eyeAt = At(s, chunk) + float2(30, -100); + var observer = SpawnShip(s, eyeAt, sensor: true); + Tick((observer, eyeAt)); + Assert.True(observer.SetTarget(chunk)); + + for (var tick = 0; tick < 5; tick++) Tick((observer, eyeAt)); + Assert.True(observer.Target.Value.Equals(new TargetRef(chunk))); + Assert.Equal(length(float2(30, -100)), observer.TargetRange, 3); + + observer.Deactivate(); + observer.Activate(); + Assert.True(observer.Target.Value.Equals(new TargetRef(chunk))); + } + + // The lost-track rule through the chunk path: a chunk target is dropped once this entity cannot see it -- + // on the next tick, or at Activate when it went dark while docked. + [Fact] + public void AChunkTargetIsDroppedWhenItGoesDark() + { + var kind = Kind("Asteroid", 4f); + var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f), Rock(150f, .25f), Rock(150f, .5f))); + var eyeAt = float2(0, 0); + + // Its own sensor goes off: nothing sees the chunk, and the next tick drops it. + var blinded = SpawnShip(s, eyeAt, sensor: true); + Tick((blinded, eyeAt)); + var first = new ChunkId(s.Belts[0], 0); + Assert.True(blinded.SetTarget(first)); + blinded.Equipment.Single(item => item.Data.Name == "Eye").Enabled.Value = false; + Tick((blinded, eyeAt)); + Assert.True(blinded.Target.Value.IsNone); + + // The chunk breaks: it is dark to everyone, and the next tick drops it. + var watcher = SpawnShip(s, eyeAt, sensor: true); + Tick((watcher, eyeAt)); + var second = new ChunkId(s.Belts[0], 1); + Assert.True(watcher.SetTarget(second)); + Tick((watcher, eyeAt)); + Assert.False(watcher.Target.Value.IsNone); + Assert.True(s.Zone.Wear(second, 1e6f)); + Tick((watcher, eyeAt)); + Assert.True(watcher.Target.Value.IsNone); + + // It breaks while the holder is docked: Activate drops it before any tick runs. + var docked = SpawnShip(s, eyeAt, sensor: true); + Tick((docked, eyeAt)); + var third = new ChunkId(s.Belts[0], 2); + Assert.True(docked.SetTarget(third)); + docked.Deactivate(); + Assert.True(s.Zone.Wear(third, 1e6f)); + docked.Activate(); + Assert.True(docked.Target.Value.IsNone); + } + + // One slot, two kinds, value equality: the same chunk is the same target, a chunk is never an entity, and an + // empty target is None whichever way it was made. + [Fact] + public void TargetRefEquality() + { + var s = BuildScene(); + var ship = SpawnShip(s, float2(0, 0)); + var other = SpawnShip(s, float2(10, 0)); + var field = new CultRecordKey("field"); + var chunk = new ChunkId(field, 3); + + Assert.True(new TargetRef(chunk).Equals(new TargetRef(new ChunkId(new CultRecordKey("field"), 3)))); + Assert.Equal(new TargetRef(chunk).GetHashCode(), new TargetRef(new ChunkId(new CultRecordKey("field"), 3)).GetHashCode()); + Assert.False(new TargetRef(chunk).Equals(new TargetRef(new ChunkId(field, 4)))); + Assert.False(new TargetRef(chunk).Equals(new TargetRef(new ChunkId(new CultRecordKey("other"), 3)))); + + Assert.True(new TargetRef(ship).Equals(new TargetRef(ship))); + Assert.False(new TargetRef(ship).Equals(new TargetRef(other))); + Assert.False(new TargetRef(ship).Equals(new TargetRef(chunk))); + Assert.False(new TargetRef(chunk).Equals(new TargetRef(ship))); + Assert.False(new TargetRef(ship).Equals(TargetRef.None)); + + Assert.True(TargetRef.None.IsNone); + Assert.True(new TargetRef((Entity) null).IsNone); + Assert.True(new TargetRef((Entity) null).Equals(TargetRef.None)); + Assert.False(new TargetRef(default(ChunkId)).IsNone); + Assert.False(new TargetRef(default(ChunkId)).Equals(TargetRef.None)); + Assert.False(new TargetRef(ship).IsNone); + Assert.True(((object) new TargetRef(chunk)).Equals(new TargetRef(chunk))); + Assert.Equal(new TargetRef(ship).GetHashCode(), new TargetRef(ship).GetHashCode()); + Assert.Equal(TargetRef.None.GetHashCode(), new TargetRef((Entity) null).GetHashCode()); + } + + // Q12 A: a launcher never locks a chunk. The same lock weapon, geometry and detection build lock on a + // hostile ship; switched to a chunk, the lock drops to zero and stays there. + [Fact] + public void ChunkTargetIsNeverLocked() + { + var kind = Kind("Asteroid", 4f); + var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f))); + var chunk = new ChunkId(s.Belts[0], 0); + var eyeAt = At(s, chunk) + float2(0, -100); + var shooter = SpawnShip(s, eyeAt, sensor: true, weaponRanges: 300f); + Tick((shooter, eyeAt)); + var enemy = SpawnShip(s, eyeAt + float2(0, 50)); + shooter.SetIff(enemy, true); + shooter.EntityInfoGathered[enemy] = 1; + shooter.LookDirection = float3(0, 0, 1); + var launcher = new LockWeapon(new LockWeaponData + { + LockSpeed = Constant(.5f), + LockAngle = Constant(180f), + DirectionImpact = Constant(1f) + }, shooter.Equipment.Single(item => item.Behaviors.Any(b => b is Weapon))); + + Assert.True(shooter.SetTarget(enemy)); + launcher.Execute(1f); + Assert.True(launcher.Lock > 0f, "positive control: the launcher locks a hostile ship"); + + Assert.True(shooter.SetTarget(chunk)); + launcher.Execute(1f); + Assert.Equal(0f, launcher.Lock); + launcher.Execute(1f); + Assert.Equal(0f, launcher.Lock); + } + + // Q16 B, the pick itself: a weighted choice by the belt's key -- the same key always gives the same kind, a + // kind of weight zero is never chosen, and the others come up in proportion to their weights. + [Fact] + public void FieldKindAssignmentIsAWeightedPickByKey() + { + var light = Kind("Light", 1f, 1f); + var never = Kind("Never", 1f, 0f); + var middle = Kind("Middle", 1f, 2f); + var heavy = Kind("Heavy", 1f, 3f); + var s = BuildScene(new[] { light, never, middle, heavy }); + var counts = new Dictionary { ["Light"] = 0, ["Never"] = 0, ["Middle"] = 0, ["Heavy"] = 0 }; + for (var i = 0; i < 2000; i++) + { + var key = new CultRecordKey($"belt-{i}"); + var picked = FieldKinds.Assign(key, s.Cache); + Assert.True(picked.Key.Equals(FieldKinds.Assign(key, s.Cache).Key)); + counts[s.Cache.Get(picked).Name]++; + } + Assert.Equal(0, counts["Never"]); + Assert.InRange(counts["Light"] / 2000f, 1f / 6 - .04f, 1f / 6 + .04f); + Assert.InRange(counts["Middle"] / 2000f, 2f / 6 - .04f, 2f / 6 + .04f); + Assert.InRange(counts["Heavy"] / 2000f, 3f / 6 - .04f, 3f / 6 + .04f); + + var empty = BuildScene(new[] { never }); + Assert.False(FieldKinds.Assign(new CultRecordKey("belt-0"), empty.Cache).IsSet()); + } + + // Q16 B, first load: a belt saved before field kinds existed gets its kind when a zone first loads it, by the + // same function generation uses, and the assignment is saved -- so a later catalog edit that would pick a + // different kind for that belt changes nothing about it. + [Fact] + public void FieldKindAssignmentIsOneFunction() + { + var a = Kind("A", 1f, 1f); + var b = Kind("B", 2f, 1f); + var s = BuildScene(new[] { a, b }, Belt(null, Rock(150f))); + var beltKey = s.Belts[0]; + var belt = (AsteroidBeltData) s.Zone.Planets[beltKey]; + Assert.True(belt.Kind.IsSet()); + Assert.True(belt.Kind.Key.Equals(FieldKinds.Assign(beltKey, s.Cache).Key)); + var assigned = belt.Kind.Key; + + // Saved: a fresh open of the same stores reads the kind back. + s.Cache.FlushAsync().Wait(); + s.Cache.Dispose(); + _caches.Remove(s.Cache); + var dir = Path.Combine(_root, $"scene{_fixtureCount - 1}"); + var reopened = AetheriaStores.Open(Path.Combine(dir, "Aetheria.cc"), Path.Combine(dir, "run.cc"), catalogWritable: true); + _caches.Add(reopened); + Assert.True(((AsteroidBeltData) reopened.Get(beltKey)).Kind.Key.Equals(assigned)); + + // Kept: a new, overwhelmingly weighted kind now wins the pick for this belt, yet loading it again leaves it. + reopened.Upsert(Kind("Overwhelming", 9f, 1e6f)); + Assert.False(FieldKinds.Assign(beltKey, reopened).Key.Equals(assigned)); + var items = new ItemManager(reopened, new ProvenanceLedger(), GameSettings(), _ => { }); + var reloaded = new Zone(items, PlanetSettings(), s.Pack, new GalaxyZone { Name = "MiningCut3Reload", Owner = null }, null); + Assert.True(((AsteroidBeltData) reloaded.Planets[beltKey]).Kind.Key.Equals(assigned)); + } + + // Q14 A with two sensors: each runs the per-tick rule on the same info, so each adds its gain and each applies + // the decay. Two identical sensors settle exactly where one does -- the on-demand value divides the summed rate + // by the number of sensors as well as by the decay. + [Fact] + public void ChunkInfoMatchesAnEntityHeldStillUnderTwoSensors() + { + var kind = Kind("Asteroid", 1f); + var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f))); + var chunk = new ChunkId(s.Belts[0], 0); + var chunkAt = At(s, chunk); + var eyeAt = float2(50, -150); + var observer = SpawnShip(s, eyeAt, sensor: true, secondSensor: true); + var cells = s.Zone.ChunkRadius(chunk) / 2f; + var rock = SpawnShip(s, chunkAt, crossSection: kind.CrossSection * PI * cells * cells); + + for (var tick = 0; tick < 600; tick++) Tick((rock, chunkAt), (observer, eyeAt)); + + var settled = observer.ChunkInfo(chunk); + var perTick = observer.EntityInfoGathered[rock]; + Assert.InRange(settled, .12f, .9f); + var discrete = settled * (1f - GameSettings().TargetInfoDecay * Dt); + Assert.True(abs(perTick - discrete) <= 2e-3f * settled, + $"per-tick loop settled at {perTick}, the on-demand value implies {discrete} (settled {settled})"); + } + + // The per-tick rule saturates info at 1, so the settled value is capped there too: a bright chunk close by is + // fully known, not more than fully. + [Fact] + public void ChunkInfoIsCappedAtOne() + { + var kind = Kind("Asteroid", 4f); + var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f))); + var chunk = new ChunkId(s.Belts[0], 0); + var eyeAt = At(s, chunk) + float2(0, -30); + var observer = SpawnShip(s, eyeAt, sensor: true); + Tick((observer, eyeAt)); + Assert.Equal(1f, observer.ChunkInfo(chunk)); + } + + // A chunk target bears through its own planar position: the arc gate passes a chunk ahead and refuses one off + // to the side (a 90-degree arc facing +z), and a chunk has no stance to safe a weapon against. + [Fact] + public void AChunkTargetBearsThroughItsOwnPosition() + { + var kind = Kind("Asteroid", 4f); + var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f, .25f), Rock(150f))); + var ahead = new ChunkId(s.Belts[0], 0); // (0, 150) + var aside = new ChunkId(s.Belts[0], 1); // (150, 0) + var eyeAt = float2(-10, 50); // ahead is at (10, 100) from here, aside at (160, -50) + var shooter = SpawnShip(s, eyeAt, sensor: true, weaponRanges: 300f); + Tick((shooter, eyeAt)); + var gun = shooter.Weapons.Single(); + + Assert.True(shooter.SetTarget(ahead)); + Assert.True(gun.ArcAllowsFire); + Assert.True(gun.StanceAllowsFire); + + Assert.True(shooter.SetTarget(aside)); + Assert.False(gun.ArcAllowsFire); + Assert.True(gun.StanceAllowsFire); + } + + // A writable copy of the shipped catalog plus a scratch run store, through a registry scoped to the shipped + // assembly's own document types (RestoredHullsTests' composition): this test assembly's own catalog global + // would otherwise be demanded of a catalog that was never authored with it. + private static CultCache OpenShippedCatalogCopy(string catalogPath, string runPath) + { + var registry = CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes() + .Where(t => t is { IsAbstract: false, IsInterface: false }) + .Where(t => t.GetCustomAttributes(typeof(CultDocumentAttribute), false).Length > 0)); + var cache = new CultCache(registry); + cache.AddBackingStore(new GameCult.Caching.MessagePack.SingleFileMessagePackBackingStore(catalogPath, false), AetheriaStores.CatalogTypes); + cache.AddBackingStore(new GameCult.Caching.MessagePack.SingleFileMessagePackBackingStore(runPath), AetheriaStores.RunTypes); + return cache; + } + + // Q16 B, generation: every belt ZoneGenerator makes carries its kind already, chosen by FieldKinds.Assign on + // the belt's own key. Runs against a scratch copy of the shipped catalog with three kinds of different + // weights, so a pick by any other rule would disagree on some belt. + [Fact] + public void GenerationChoosesTheKindByTheSameFunction() + { + var shipped = Path.Combine(RestoredHullsTests.FindRepoRoot(), "GameData", "Aetheria.cc"); + var belts = 0; + for (uint seed = 1; seed <= 25 && belts < 3; seed++) + { + var dir = Path.Combine(_root, $"gen{seed}"); + Directory.CreateDirectory(dir); + var catalog = Path.Combine(dir, "Aetheria.cc"); + File.Copy(shipped, catalog); + using var cache = OpenShippedCatalogCopy(catalog, Path.Combine(dir, "run.cc")); + foreach (var existing in cache.GetAll().ToArray()) + { + existing.GenerationWeight = 0f; + cache.Upsert(existing); + } + cache.Upsert(Kind("Gen A", 1f, 1f)); + cache.Upsert(Kind("Gen B", 1f, 2f)); + cache.Upsert(Kind("Gen C", 1f, 4f)); + + Galaxy galaxy; + try + { + galaxy = new Galaxy(RestoredHullsTests.TutorialGalaxySettings(), RestoredHullsTests.TutorialBackgroundSettings(), + RestoredHullsTests.TutorialNameSettings(), cache, new PlayerSettings(), new DirectoryInfo(dir), _ => { }, null, seed); + } + catch (Exception) { continue; } // Galaxy's own seed-dependent flakiness (RestoredHullsTests) + var items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { }); + foreach (var zone in galaxy.Zones.Take(4)) + { + var pack = ZoneGenerator.GenerateZone(items, RestoredHullsTests.TutorialZoneSettings(), galaxy, zone, isTutorial: zone == galaxy.Entrance); + foreach (var body in pack.Planets) + { + if (!(cache.Get(body) is AsteroidBeltData belt)) continue; + belts++; + Assert.True(belt.Kind.IsSet()); + Assert.True(belt.Kind.Key.Equals(FieldKinds.Assign(body.Key, cache).Key)); + } + } + } + Assert.True(belts > 0, "no seed generated a belt; the generation path went unexercised"); + } + + // ==== Mining Cut 3 fix batch (Soul's findings) ==== + + // Q12 A ("launchers cannot mine") in the reach rule: reach counts only weapons that can mine (Weapon.CanMine). + // Two visible rocks, 150 and 250 away. A launcher of range 400 alone is offered neither; a gun of range 200 + // beside it is offered the near one only, so the launcher's range still counts for nothing. + [Fact] + public void LaunchersDoNotCountTowardChunkReach() + { + var kind = Kind("Asteroid", 40f); + var eyeAt = float2(150, -250); + var nearAt = float2(150, -100); + var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f), Rock(length(nearAt), 1f + atan2(nearAt.y, nearAt.x) / (2 * PI)))); + var far = new ChunkId(s.Belts[0], 0); + var near = new ChunkId(s.Belts[0], 1); + Assert.Equal(250f, length(At(s, far) - eyeAt), 2); + Assert.Equal(150f, length(At(s, near) - eyeAt), 2); + var launcher = Gear("Launcher", new LockWeaponData { Range = Constant(400f), LockSpeed = Constant(.5f), LockAngle = Constant(30f), DirectionImpact = Constant(1f) }); + s.Cache.Upsert(launcher); + var gun = Gear("Gun 200", new InstantWeaponData { Range = Constant(200f) }); + s.Cache.Upsert(gun); + + Ship Fit(params GearData[] weapons) + { + var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings()); + Assert.True(ship.TryEquip(Mint(s, s.Eye))); + foreach (var weapon in weapons) Assert.True(ship.TryEquip(Mint(s, weapon))); + s.Zone.Entities.Add(ship); + Hold(ship, eyeAt); + ship.LookDirection = float3(0, 0, 1); + ship.Activate(); + Tick((ship, eyeAt)); // weapons read their ranges when they run + return ship; + } + + var offered = new List(); + var launcherOnly = Fit(launcher); + Assert.Equal(400f, launcherOnly.Weapons.Single().Range); + Assert.True(launcherOnly.ChunkVisible(far)); + Assert.True(launcherOnly.ChunkVisible(near)); + launcherOnly.VisibleChunksInReach(offered); + Assert.Empty(offered); + + var withGun = Fit(launcher, gun); + withGun.VisibleChunksInReach(offered); + Assert.Equal(new[] { near }, offered); + } + + // Q13 A reads Active, not Enabled (Soul F8): a gun that is switched on but offline (worn out) adds no reach. + [Fact] + public void AnOfflineWeaponAddsNoReach() + { + var kind = Kind("Asteroid", 4f); + var s = BuildScene(new[] { kind }, + Belt(kind, Rock(150f), Rock(length(float2(150, 100)), atan2(100f, 150f) / (2 * PI)))); + var near = new ChunkId(s.Belts[0], 0); + var farther = new ChunkId(s.Belts[0], 1); + var eyeAt = float2(150, -200); + var observer = SpawnShip(s, eyeAt, sensor: true, weaponRanges: new[] { 250f, 350f }); + Tick((observer, eyeAt)); + var offered = new List(); + observer.VisibleChunksInReach(offered); + Assert.Equal(new[] { near, farther }, offered); + + var longGun = observer.Equipment.Single(item => item.Data.Name == "Gun 350"); + longGun.EquippableItem.Durability = 0f; + Tick((observer, eyeAt)); + Assert.True(longGun.Enabled.Value); + Assert.False(longGun.Active.Value); + observer.VisibleChunksInReach(offered); + Assert.Equal(new[] { near }, offered); + } + + // Q16 B on the clean-store path (Soul P2): a belt saved kindless, reopened from disk with nothing dirty, and + // loaded by a Zone, has its first-load kind saved by the next flush. + [Fact] + public void AFirstLoadKindIsSavedFromACleanStore() + { + var kind = Kind("Asteroid", 4f); + var s = BuildScene(new[] { kind }); + var beltOrbit = s.Cache.Upsert(new OrbitData { Parent = s.Pack.Orbits[0], Distance = 150f }).Key; + var beltKey = s.Cache.Upsert(new AsteroidBeltData { Orbit = new CultRecordRef(beltOrbit), Asteroids = new[] { Rock(150f) } }).Key; + s.Pack.Orbits.Add(new CultRecordRef(beltOrbit)); + s.Pack.Planets.Add(new CultRecordRef(beltKey)); + s.Cache.FlushAsync().Wait(); + s.Cache.Dispose(); + _caches.Remove(s.Cache); + var dir = Path.Combine(_root, $"scene{_fixtureCount - 1}"); + + var clean = AetheriaStores.Open(Path.Combine(dir, "Aetheria.cc"), Path.Combine(dir, "run.cc"), catalogWritable: true); + _caches.Add(clean); + Assert.False(clean.IsDirty); + Assert.False(((AsteroidBeltData) clean.Get(beltKey)).Kind.IsSet()); + var zone = new Zone(new ItemManager(clean, new ProvenanceLedger(), GameSettings(), _ => { }), PlanetSettings(), s.Pack, + new GalaxyZone { Name = "Clean", Owner = null }, null); + var assigned = ((AsteroidBeltData) zone.Planets[beltKey]).Kind; + Assert.True(assigned.IsSet()); + clean.FlushAsync().Wait(); + clean.Dispose(); + _caches.Remove(clean); + + var reopened = AetheriaStores.Open(Path.Combine(dir, "Aetheria.cc"), Path.Combine(dir, "run.cc"), catalogWritable: false); + _caches.Add(reopened); + Assert.True(((AsteroidBeltData) reopened.Get(beltKey)).Kind.Key.Equals(assigned.Key), "the first-load kind was not saved"); + } + + // SetTarget refuses a lit chunk the entity cannot see because it is too far away, not only a dark one (Soul P9). + [Fact] + public void SetTargetRefusesALitChunkBeyondSight() + { + var kind = Kind("Asteroid", 1f); + var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f))); + var chunk = new ChunkId(s.Belts[0], 0); + var eyeAt = At(s, chunk) + float2(0, -900); + var ship = SpawnShip(s, eyeAt, sensor: true); + Tick((ship, eyeAt)); + Assert.True(s.Zone.ChunkVisibility(chunk) > 0f); + Assert.InRange(ship.ChunkInfo(chunk), 1e-3f, .1f); // seen a little, below the detection threshold + Assert.False(ship.ChunkVisible(chunk)); + Assert.False(ship.SetTarget(chunk)); + Assert.True(ship.Target.Value.IsNone); + } + + // The chunk query equals brute force (Soul P10): 3000 random queries off the origin, over two belts, many of + // them from beyond a belt's outer edge where the belt skip decides. + [Fact] + public void TheChunkQueryMatchesBruteForce() + { + var kind = Kind("Asteroid", 4f); + var rng = new System.Random(11); + var rocks = Enumerable.Range(0, 400).Select(i => Rock(200f + (float) rng.NextDouble() * 150f, (float) rng.NextDouble())).ToArray(); + var centre = float2(-700, 333); + var s = BuildSceneAt(centre, new[] { kind }, Belt(kind, rocks), Belt(kind, Rock(600f), Rock(640f, .3f))); + var found = new List(); + var mismatches = 0; + var nonEmpty = 0; + var acrossOuterEdge = 0; + for (var q = 0; q < 3000; q++) + { + var angle = (float) (rng.NextDouble() * 2 * PI); + var fromCentre = (float) rng.NextDouble() * 900f; + var from = centre + fromCentre * float2(cos(angle), sin(angle)); + var range = (float) rng.NextDouble() * 300f; + ChunksWithin(s, from, range, found); + var expected = s.Belts + .SelectMany(b => Enumerable.Range(0, ((AsteroidBeltData) s.Zone.Planets[b]).Asteroids.Length).Select(i => new ChunkId(b, i))) + .Where(c => length(At(s, c) - from) <= range).ToArray(); + if (expected.Length != found.Count || !expected.ToHashSet().SetEquals(found)) mismatches++; + if (expected.Length > 0) nonEmpty++; + if (fromCentre > 350f && fromCentre - range < 350f) acrossOuterEdge++; + } + Assert.Equal(0, mismatches); + Assert.True(nonEmpty > 300 && acrossOuterEdge > 300, $"degenerate queries: {nonEmpty} non-empty, {acrossOuterEdge} across the outer edge"); + } + + // The detection loop's ping arm is unchanged by the gain extraction (Soul's ping sweep): the EntityInfoGathered + // trace of 18 sensor configurations, some with a ping that lands unsaturated, matches the trace recorded against + // the pre-extraction Sensor.Execute (cb4a1dbd). SensorGainExtractionChangesNothing does not see a ping's size; + // this sweep does. + [Fact] + public void ThePingArmIsUnchangedByTheGainExtraction() + { + var bytes = new List(); + var unsaturatedPings = 0; + foreach (var sensitivity in new[] { 1e-5f, 3e-5f, 1e-4f, 3e-4f, 1e-3f, 1e-2f }) + foreach (var boost in new[] { 1e-4f, 1e-3f, 2e-2f }) + { + var (near, far) = PingTrace(sensitivity, boost, true); + var (nearQuiet, farQuiet) = PingTrace(sensitivity, boost, false); + foreach (var x in near.Concat(far)) bytes.AddRange(BitConverter.GetBytes(x)); + if (near.Max() < .999f && near.Zip(nearQuiet, (a, b) => abs(a - b)).Max() > .01f) unsaturatedPings++; + if (far.Max() < .999f && far.Zip(farQuiet, (a, b) => abs(a - b)).Max() > .01f) unsaturatedPings++; + } + Assert.True(unsaturatedPings > 0, "no configuration saw a ping land unsaturated"); + Assert.Equal("35DE0E26ACC5BD51F6B6E170AB890C3FE5D8BB2BE3A9DC3F3EB66CDA0FA23728", Convert.ToHexString(SHA256.HashData(bytes.ToArray()))); + } + + // One observer and two reflecting ships in an empty zone, 80 ticks, a ping at tick 20 when `ping`. + private (float[] near, float[] far) PingTrace(float sensitivity, float boost, bool ping) + { + var dir = Path.Combine(_root, $"ping{_fixtureCount++}"); + Directory.CreateDirectory(dir); + var cache = AetheriaStores.Open(Path.Combine(dir, "Aetheria.cc"), Path.Combine(dir, "run.cc"), catalogWritable: true); + _caches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + var shape = new Shape(5, 5); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + var hull = new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = shape, Durability = 10, Mass = 1000 }; + cache.Upsert(hull); + var eye = Gear("Eye", new SensorData + { + Sensitivity = Constant(sensitivity), SensitivityCurve = Falloff(), PingBoost = Constant(boost), + PingEnergy = Constant(0f), PingVisibility = Constant(5f), PingRange = Constant(300f), PingCooldown = Constant(4f) + }); + cache.Upsert(eye); + var items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { }); + var s = new Scene + { + Cache = cache, Items = items, Hull = hull, Eye = eye, + Zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Ping", Owner = null }, null) + }; + var eyeAt = float2(0, 50); + var nearAt = float2(30, 120); + var farAt = float2(-150, 90); + var observer = SpawnShip(s, eyeAt, sensor: true); + var near = SpawnShip(s, nearAt, crossSection: .2f); + var far = SpawnShip(s, farAt, crossSection: 9f); + var nearTrace = new float[80]; + var farTrace = new float[80]; + for (var tick = 0; tick < 80; tick++) + { + if (tick == 20 && ping) observer.Sensor.Ping(); + Tick((near, nearAt), (far, farAt), (observer, eyeAt)); + nearTrace[tick] = observer.EntityInfoGathered[near]; + farTrace[tick] = observer.EntityInfoGathered[far]; + } + return (nearTrace, farTrace); + } + + // Soul F2: the shipped catalog authors at least one field kind that generation can pick, so a generated belt is + // never left kindless and dark. + [Fact] + public void TheShippedCatalogHasAPickableFieldKind() + { + var dir = Path.Combine(_root, "shipped"); + Directory.CreateDirectory(dir); + var catalog = Path.Combine(dir, "Aetheria.cc"); + File.Copy(Path.Combine(RestoredHullsTests.FindRepoRoot(), "GameData", "Aetheria.cc"), catalog); + using var cache = OpenShippedCatalogCopy(catalog, Path.Combine(dir, "run.cc")); + Assert.Contains(cache.GetAll(), kind => kind.GenerationWeight > 0f); + } +} diff --git a/tests/Aetheria.Shared.Tests/MiningIndexTests.cs b/tests/Aetheria.Shared.Tests/MiningIndexTests.cs new file mode 100644 index 000000000..d6880d32d --- /dev/null +++ b/tests/Aetheria.Shared.Tests/MiningIndexTests.cs @@ -0,0 +1,493 @@ +/* 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; +using float4 = CultMath.float4; + +// Mining index (docs/aetheria-release-map.md, L3-L5; Soul F1, operator 2026-10-01: "Fix the belt freeze"): the zone's +// targeting index answers range and best-first queries from providers, and a query near a few rocks examines few +// rocks however large the belt. Every answer is compared with a test-local scan of every rock. +public sealed partial class MiningCut3Tests +{ + private static void ChunksWithin(Scene s, float2 from, float range, List into) + { + var candidates = new List(); + s.Zone.Targets.Within(new TargetSearch(from, range), candidates); + into.Clear(); + into.AddRange(candidates.Where(c => c.Target.Chunk.HasValue).Select(c => c.Target.Chunk.Value)); + } + + private static IEnumerable AllChunks(Scene s) => + s.Belts.SelectMany(b => Enumerable.Range(0, ((AsteroidBeltData) s.Zone.Planets[b]).Asteroids.Length).Select(i => new ChunkId(b, i))); + + // Rocks spread evenly over the annulus [inner, outer]: `count` of them at the same density per area as any other + // call with the same density. + private static Asteroid[] Annulus(System.Random rng, int count, float inner, float outer) => + Enumerable.Range(0, count).Select(_ => + Rock(sqrt(inner * inner + (float) rng.NextDouble() * (outer * outer - inner * inner)), (float) rng.NextDouble(), + (float) rng.NextDouble())).ToArray(); + + // A ship at `at` with one sensor of the given sensitivity and curve, and one gun of `reach`. + private Ship Observer(Scene s, float2 at, float reach, float sensitivity, BezierCurve curve) + { + var eye = Gear($"Eye {Guid.NewGuid():N}", new SensorData + { + Sensitivity = Constant(sensitivity), + SensitivityCurve = curve, + PingBoost = Constant(.02f), + PingEnergy = Constant(0f), + PingVisibility = Constant(5f), + PingRange = Constant(300f), + PingCooldown = Constant(4f) + }); + s.Cache.Upsert(eye); + var gun = Gear($"Gun {reach}", new InstantWeaponData { Range = Constant(reach) }); + s.Cache.Upsert(gun); + var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings()); + Assert.True(ship.TryEquip(Mint(s, eye))); + Assert.True(ship.TryEquip(Mint(s, gun))); + s.Zone.Entities.Add(ship); + Hold(ship, at); + ship.LookDirection = float3(0, 0, 1); + ship.Activate(); + Tick((ship, at)); // equipment comes online and weapons read their ranges on the first tick + return ship; + } + + // Within's chunks, at random positions and times -- including after time jumps that shear every band past its + // re-key threshold many times over -- equal the scan of every rock, broken rocks left out. + [Fact] + public void WithinIsExactlyTheBruteForceSet() + { + var kind = Kind("Asteroid", 4f); + var rng = new System.Random(5); + var centre = float2(-300, 820); + var s = BuildSceneAt(centre, 5000f, 1234.5, null, new[] { kind }, + Belt(kind, Annulus(rng, 3000, 200f, 900f)), Belt(kind, Annulus(rng, 700, 1000f, 1060f))); + for (var i = 0; i < 40; i++) Assert.True(s.Zone.Wear(new ChunkId(s.Belts[0], i * 73), 1e6f)); + + var found = new List(); + var nonEmpty = 0; + foreach (var step in new[] { 0f, 3f, 250f, 4e4f, 9e5f, 1.7f, 3e6f }) + { + if (step > 0f) s.Zone.Update(step); + for (var q = 0; q < 60; q++) + { + var angle = (float) (rng.NextDouble() * 2 * PI); + var from = centre + (float) rng.NextDouble() * 1200f * float2(cos(angle), sin(angle)); + var range = (float) rng.NextDouble() * 250f; + ChunksWithin(s, from, range, found); + var expected = AllChunks(s).Where(c => s.Zone.ChunkExists(c) && length(At(s, c) - from) <= range).ToList(); + Assert.Equal(expected.Count, found.Count); + Assert.True(expected.ToHashSet().SetEquals(found), $"step {step}, query {q}: {expected.Count} expected, {found.Count} found"); + if (expected.Count > 0) nonEmpty++; + } + } + Assert.True(nonEmpty > 200, $"degenerate queries: {nonEmpty} non-empty"); + } + + // 4,000 rocks over 300..1300 about `centre`, and a second sun off the belt centre, so the light bound's bearing to + // a sun matters. + private Scene TwoSunBelt(System.Random rng, float2 centre) + { + var kind = Kind("Asteroid", 4f); + return BuildSceneAt(centre, 1500f, 0, new[] { centre + float2(900, 400) }, new[] { kind }, Belt(kind, Annulus(rng, 4000, 300f, 1300f))); + } + + // Every bound a belt region states holds for every rock it hands back: distance, bearing and visibility. + [Fact] + public void BeltRegionBoundsHoldForEveryRock() + { + var rng = new System.Random(8); + var centre = float2(150, -60); + var s = TwoSunBelt(rng, centre); + var data = (AsteroidBeltData) s.Zone.Planets[s.Belts[0]]; + var belt = new BeltTargets(s.Zone, s.Belts[0], data, s.Zone.Settings, 0); + var regions = new List(); + var candidates = new List(); + var checkedRocks = 0; + foreach (var step in new[] { 0f, 50f, 2e5f }) + { + if (step > 0f) s.Zone.Update(step); + for (var q = 0; q < 40; q++) + { + // Every fourth search starts near the centre and reaches the whole belt, so sectors across the + // searcher's antipode are bounded too. + var whole = q % 4 == 0; + var angle = (float) (rng.NextDouble() * 2 * PI); + var from = centre + (float) rng.NextDouble() * (whole ? 120f : 1500f) * float2(cos(angle), sin(angle)); + var search = new TargetSearch(from, whole ? 1500f : 50f + (float) rng.NextDouble() * 400f); + regions.Clear(); + belt.Regions(search, regions); + foreach (var region in regions) + { + candidates.Clear(); + belt.Candidates(search, region, candidates); + foreach (var c in candidates) + { + var to = c.Position - from; + var d = length(to); + Assert.InRange(d, region.Nearest, region.Farthest); + var off = abs(atan2(to.y, to.x) - region.BearingCentre); + off = min(off, 2 * PI - off); + Assert.True(off <= region.BearingHalfWidth, $"bearing {off} outside {region.BearingHalfWidth}"); + Assert.True(s.Zone.ChunkVisibility(c.Target.Chunk.Value) <= region.MaxVisibility); + checkedRocks++; + } + } + } + } + Assert.True(checkedRocks > 5000, $"only {checkedRocks} rocks checked"); + } + + // The visibility bound never drops a visible rock: over a belt the sun lights unevenly, at two sensor + // sensitivities and two curve shapes (one whose tangents overshoot its keys), VisibleChunksInReach equals the scan + // of every rock in reach that ChunkVisible accepts -- and the bound did prune some. + [Fact] + public void VisibilityPruningNeverDropsAVisibleChunk() + { + var kind = Kind("Asteroid", 4f); + var rng = new System.Random(13); + // The sun at the belt centre, its light gone by 750: the belt runs from bright to dark. + var s = BuildSceneAt(float2.zero, 1500f, 0, null, new[] { kind }, Belt(kind, Annulus(rng, 4000, 200f, 1200f))); + var overshoot = new BezierCurve { Keys = new[] { new float4(0, .4f, 0, 3f), new float4(1, .1f, -2f, 0) } }; + var offered = new List(); + var visibleSeen = 0; + var pruned = 0; + foreach (var sensitivity in new[] { 3f, .05f }) + foreach (var curve in new[] { Falloff(), overshoot }) + for (var q = 0; q < 8; q++) + { + var angle = (float) (rng.NextDouble() * 2 * PI); + var at = (200f + (float) rng.NextDouble() * 1000f) * float2(cos(angle), sin(angle)); + var observer = Observer(s, at, 350f, sensitivity, curve); + var before = s.Zone.Targets.Examined; + observer.VisibleChunksInReach(offered); + var examined = s.Zone.Targets.Examined - before; + var inReach = AllChunks(s).Where(c => s.Zone.ChunkExists(c) && length(At(s, c) - at) <= 350f).ToList(); + var expected = inReach.Where(observer.ChunkVisible).ToList(); + Assert.True(expected.ToHashSet().SetEquals(offered), $"sensitivity {sensitivity}: {expected.Count} visible, {offered.Count} offered"); + visibleSeen += expected.Count; + if (examined < inReach.Count) pruned++; + s.Zone.Entities.Remove(observer); + } + Assert.True(visibleSeen > 50 && pruned > 4, $"degenerate: {visibleSeen} visible, {pruned} queries pruned"); + } + + // The operator's rule for F1: the cost of a key press must not grow with belt size. Belts of 30k and 300k rocks at + // one density, the observer in the same neighbourhood: one VisibleChunksInReach examines about as many rocks in + // both, and under 2% of the larger belt. + [Fact] + public void AQueryNearAFewRocksExaminesFewRocks() + { + var kind = Kind("Asteroid", 4f); + // 100 square units per rock: 30k rocks fill 2000..2226, 300k fill 2000..3681. + long Examine(int count) + { + 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()); + var offered = new List(); + var before = s.Zone.Targets.Examined; + observer.VisibleChunksInReach(offered); + Assert.NotEmpty(offered); + var cost = s.Zone.Targets.Examined - before; + // Time passes, too little to shear a band past re-keying: the arcs widen by their drift only. + s.Zone.Update(5f); + Tick((observer, at)); + before = s.Zone.Targets.Examined; + observer.VisibleChunksInReach(offered); + Assert.True(s.Zone.Targets.Examined - before < 2 * cost, $"after 5 s, examined {s.Zone.Targets.Examined - before} against {cost}"); + return cost; + } + var small = Examine(30_000); + var large = Examine(300_000); + Assert.True(large < 2 * small && small < 2 * large, $"examined {small} at 30k, {large} at 300k"); + Assert.True(large < 300_000 / 50, $"examined {large} of 300k"); + } + + // A belt outside every sun's light: VisibleChunksInReach examines none of its rocks, while Within with no + // visibility bound still finds them. + [Fact] + public void DarkBandsAreSkippedWhole() + { + var kind = Kind("Asteroid", 4f); + var s = BuildSceneAt(float2.zero, 1000f, 0, null, new[] { kind }, Belt(kind, Annulus(new System.Random(3), 2000, 1500f, 1600f))); + var at = float2(1550f, 0f); + var observer = Observer(s, at, 300f, 3f, Falloff()); + var offered = new List(); + var before = s.Zone.Targets.Examined; + observer.VisibleChunksInReach(offered); + Assert.Equal(0, s.Zone.Targets.Examined - before); + Assert.Empty(offered); + var found = new List(); + ChunksWithin(s, at, 300f, found); + Assert.NotEmpty(found); + } + + // After a time jump that shears every band, one query re-sorts at most the budget of bands and still returns the + // scan's answer: the bands it did not re-sort are searched through their widened arcs. + [Fact] + public void ReKeyingIsBoundedPerQuery() + { + var kind = Kind("Asteroid", 4f); + var s = BuildSceneAt(float2.zero, 5000f, 1e5, null, new[] { kind }, Belt(kind, Annulus(new System.Random(17), 256 * 40, 200f, 1200f))); + var data = (AsteroidBeltData) s.Zone.Planets[s.Belts[0]]; + var belt = new BeltTargets(s.Zone, s.Belts[0], data, s.Zone.Settings, 1e5); + var whole = new TargetSearch(float2(700, 0), 2000f); + var regions = new List(); + // Ten seconds shear no band past the limit, however late the zone's clock: nothing is re-keyed. + s.Zone.Update(10f); + var before = belt.Rekeys; + belt.Regions(whole, regions); + Assert.Equal(0, belt.Rekeys - before); + regions.Clear(); + s.Zone.Update(5e6f); + before = belt.Rekeys; + belt.Regions(whole, regions); + Assert.Equal(8, belt.Rekeys - before); + regions.Clear(); + belt.Regions(whole, regions); + Assert.Equal(16, belt.Rekeys - before); + + var found = new List(); + foreach (var (from, range) in new[] { (float2(700, 0), 120f), (float2(-300, 500), 300f), (float2(0, -1100), 90f) }) + { + ChunksWithin(s, from, range, found); + var expected = AllChunks(s).Where(c => length(At(s, c) - from) <= range).ToList(); + Assert.NotEmpty(expected); + Assert.True(expected.ToHashSet().SetEquals(found) && expected.Count == found.Count); + } + } + + // Perception stays Entity's: an entity the observer has not detected is never offered, even inside reach; one it + // has detected is; and a search with no observer offers no entities at all. + [Fact] + public void EntitiesComeFromPerception() + { + var s = BuildScene(); + var here = float2(300, 200); + var observer = SpawnShip(s, here); + var seen = SpawnShip(s, here + float2(40, 0)); + var unseen = SpawnShip(s, here + float2(0, 40)); + var candidates = new List(); + s.Zone.Targets.Within(new TargetSearch(here, 500f, observer), candidates); + Assert.Empty(candidates); + + observer.VisibleEntities.Add(seen); + s.Zone.Targets.Within(new TargetSearch(here, 50f, observer), candidates); + Assert.Equal(new[] { new TargetRef(seen) }, candidates.Select(c => c.Target)); + Assert.Equal(here + float2(40, 0), candidates[0].Position); + + s.Zone.Targets.Within(new TargetSearch(here, 20f, observer), candidates); + Assert.Empty(candidates); + s.Zone.Targets.Within(new TargetSearch(here, 500f), candidates); + Assert.Empty(candidates); + Assert.DoesNotContain(unseen, observer.VisibleEntities); + } + + private readonly struct NearestChunk : ITargetKey + { + private readonly float2 _from; + public NearestChunk(float2 from) => _from = from; + public float Bound(in TargetRegion region) => region.Nearest; + public bool Key(in TargetCandidate candidate, out float key) + { + key = length(candidate.Position - _from); + return candidate.Target.Chunk.HasValue; + } + public bool Precedes(in TargetCandidate a, in TargetCandidate b) => a.Target.Chunk.Value.Index < b.Target.Chunk.Value.Index; + } + + // Best by distance is the scan's nearest rock in reach, and examines fewer rocks than the range query. + [Fact] + public void BestIsTheBruteForceNearest() + { + var kind = Kind("Asteroid", 4f); + var rng = new System.Random(29); + var s = BuildSceneAt(float2.zero, 5000f, 0, null, new[] { kind }, Belt(kind, Annulus(rng, 5000, 300f, 1100f))); + s.Zone.Update(777f); + long bestCost = 0, withinCost = 0; + var candidates = new List(); + for (var q = 0; q < 100; q++) + { + var angle = (float) (rng.NextDouble() * 2 * PI); + var from = (float) rng.NextDouble() * 1300f * float2(cos(angle), sin(angle)); + var search = new TargetSearch(from, 40f + (float) rng.NextDouble() * 300f); + var expected = AllChunks(s).Where(c => length(At(s, c) - from) <= search.Reach) + .OrderBy(c => length(At(s, c) - from)).ThenBy(c => c.Index).ToList(); + var before = s.Zone.Targets.Examined; + var found = s.Zone.Targets.Best(search, new NearestChunk(from), out var best); + bestCost += s.Zone.Targets.Examined - before; + Assert.Equal(expected.Count > 0, found); + if (found) Assert.Equal(expected[0], best.Target.Chunk.Value); + before = s.Zone.Targets.Examined; + s.Zone.Targets.Within(search, candidates); + withinCost += s.Zone.Targets.Examined - before; + } + Assert.True(bestCost * 2 < withinCost, $"best examined {bestCost}, within {withinCost}"); + } + + // The hull bound holds: no sample of a curve with overshooting and stepped segments leaves HullRange. + [Fact] + public void ACurveStaysWithinItsHullRange() + { + var curve = new BezierCurve + { + Keys = new[] + { + new float4(0, .4f, 0, 6f), new float4(.5f, .1f, -2f, float.PositiveInfinity), new float4(.7f, .9f, 0, -4f), + new float4(1, .2f, 1f, 0) + } + }; + var range = curve.HullRange(); + for (var i = -10; i <= 1010; i++) + { + var value = curve.Evaluate(i / 1000f); + Assert.InRange(value, range.x, range.y); + } + Assert.True(range.y > 1f, $"hull max {range.y} does not reach past the keys"); + } + + // Rocks of one Size spread evenly over the annulus [inner, outer]. + private static Asteroid[] AnnulusOfSize(System.Random rng, int count, float inner, float outer, float size) => + Enumerable.Range(0, count).Select(_ => + Rock(sqrt(inner * inner + (float) rng.NextDouble() * (outer * outer - inner * inner)), (float) rng.NextDouble(), size)).ToArray(); + + // A belt's regions stay near the search's arc: of the regions a small search gets, few lie wholly past its reach + // (the arc window is not widened), and some do (the sector's nearest distance is not loosened toward zero). + // Measured on a4e8e147 over these 120 searches, as the share of regions with Nearest > Reach: unmutated 3.97% + // (15 of 378). Window widened: BeltTargets.cs:111 asin * 2pi 82.96%, :111 asin(reach * distance) 85.89%, :131 + // 2 / half 85.89%, :140 Math.Max(to, 1) 75.29%. Nearest loosened: :247 / 2 * pi 0.00%. The ceiling, 20%, is 5.0x + // the unmutated share and 3.8x below the least widened mutant; the floor, 2%, is half the unmutated share. + [Fact] + public void RegionsStayNearTheSearchArc() + { + var rng = new System.Random(8); + var s = TwoSunBelt(rng, float2(150, -60)); + var data = (AsteroidBeltData) s.Zone.Planets[s.Belts[0]]; + var belt = new BeltTargets(s.Zone, s.Belts[0], data, s.Zone.Settings, 0); + var regions = new List(); + var searches = new System.Random(31); + int total = 0, past = 0; + // At the key time, and after 5 s, when the arcs have widened by their drift. + foreach (var step in new[] { 0f, 5f }) + { + if (step > 0f) s.Zone.Update(step); + for (var q = 0; q < 60; q++) + { + var rock = At(s, new ChunkId(s.Belts[0], searches.Next(data.Asteroids.Length))); + var angle = (float) (searches.NextDouble() * 2 * PI); + var from = rock + (float) searches.NextDouble() * 20f * float2(cos(angle), sin(angle)); + var search = new TargetSearch(from, 30f + (float) searches.NextDouble() * 50f); + regions.Clear(); + belt.Regions(search, regions); + total += regions.Count; + past += regions.Count(r => r.Nearest > search.Reach); + } + } + var share = (double) past / total; + Assert.True(share < .2, $"{past} of {total} regions lie past reach: the search window has widened"); + Assert.True(share > .02, $"{past} of {total} regions lie past reach: region distances have loosened"); + } + + // The visibility bound prunes: a belt of rocks all of the largest Size, so the belt-wide radius bound is exact per + // rock, lit by a sun at its centre whose light ends inside it. Summed over the spots, one VisibleChunksInReach + // each, the rocks examined are a small share of the rocks in reach. + // Measured on a4e8e147, examined over in reach: unmutated 0.162 (231 of 1,429); BeltTargets.cs:181 radius * cell + // size, the bound 16x too loose, 0.434 (620). The ceiling, 0.265, is 1.64x the unmutated share and 1.64x below + // the mutant's. (:247 / 2 * pi, which loosens region distances, also fails it at 0.318.) The spots are where + // light, not reach, decides which regions survive. In a measurement at 450 units, sector geometry decided + // (94 examined unmutated, 105 mutated), and past the light nothing is lit either way. + // The cut asked for Examined <= 2x the rocks in reach within their own detection distance. Unmutated it fails: 64 + // examined for 5 such rocks at 250 units. A region is 32 rocks of a band spanning an eighth of the orbit, so the + // segment size sets Examined, not the detectable count. + [Fact] + public void VisibilityBoundPrunesADimBelt() + { + var kind = Kind("Asteroid", 4f); + var rng = new System.Random(13); + var s = BuildSceneAt(float2.zero, 750f, 0, null, new[] { kind }, Belt(kind, AnnulusOfSize(rng, 3000, 200f, 1200f, 1f))); + var all = AllChunks(s).ToList(); + // The sensitivity at which the brightest rock is detectable 120 units away. + var brightest = all.Max(c => s.Zone.ChunkVisibility(c)); + var unit = Observer(s, float2(5000, 0), 350f, 1f, Falloff()); + var sensitivity = 120f / (brightest * unit.DetectionReachPerVisibility()); + s.Zone.Entities.Remove(unit); + + var offered = new List(); + long examined = 0, inReach = 0; + var spots = new System.Random(41); + foreach (var radius in new[] { 250f, 350f, 550f, 650f, 700f, 740f }) + { + var angle = (float) (spots.NextDouble() * 2 * PI); + var at = radius * float2(cos(angle), sin(angle)); + var observer = Observer(s, at, 350f, sensitivity, Falloff()); + Assert.InRange(brightest * observer.DetectionReachPerVisibility(), 80f, 150f); + var before = s.Zone.Targets.Examined; + observer.VisibleChunksInReach(offered); + examined += s.Zone.Targets.Examined - before; + inReach += all.Count(c => length(At(s, c) - at) <= 350f); + s.Zone.Entities.Remove(observer); + } + Assert.True(examined < .265 * inReach, $"examined {examined} of {inReach} rocks in reach: the visibility bound has loosened"); + } + + // A rock exactly at reach is found, and Within equals the scan of every rock in reach. Two belts, one spread over + // 50..6000 and one thin and dense over 999..1001, under the fixture's orbit periods and the game's (period = + // distance), across time steps up to 3.3e7 s. Every third search starts from a belt's outermost rock, radially + // outward and over 50 units past the belt's edge, so the rock lies on its band's outer bound as well as on the + // search's reach. + // Measured on a4e8e147: unmutated, none of the 240 searches miss. BeltTargets.cs:110 Reach - slack misses 94 + // (80 of them outer). :109 Outer - distance misses all 80 outer searches. That mutant shortens reach by 0.005 to + // 0.03 units only beyond a belt's edge, which drops a rock only when the rock lies on a region's bound. With + // random directions, the cut's draft for the outer third, it missed none of 80 here and none of Soul's 640. + [Fact] + public void ARockExactlyAtReachIsFound() + { + var kind = Kind("Asteroid", 4f); + var rng = new System.Random(101); + var centre = float2(40, -70); + var spread = Enumerable.Range(0, 700).Select(_ => Rock(50f + (float) rng.NextDouble() * 5950f, (float) rng.NextDouble(), (float) rng.NextDouble())).ToArray(); + var s = BuildSceneAt(centre, 20000f, 777.25, null, new[] { kind }, Belt(kind, spread), Belt(kind, Annulus(rng, 3000, 999f, 1001f))); + var game = PlanetSettings(20000f); + game.OrbitPeriod = new ExponentialCurve { Multiplier = 1f, Exponent = 1f, Constant = 0f }; + var searches = 0; + foreach (var zone in new[] { s.Zone, new Zone(s.Items, game, s.Pack, new GalaxyZone { Name = "Game periods", Owner = null }, null) }) + foreach (var step in new[] { 0f, 13f, 1e4f, 3.3e7f }) + { + if (step > 0f) zone.Update(step); + for (var q = 0; q < 30; q++) + { + var field = s.Belts[q % 2]; + var rocks = ((AsteroidBeltData) zone.Planets[field]).Asteroids; + var edge = q % 3 == 0; + var index = edge ? Enumerable.Range(0, rocks.Length).OrderBy(i => rocks[i].Distance).Last() : rng.Next(rocks.Length); + var rock = zone.ChunkPose(field, index).xy; + var angle = (float) (rng.NextDouble() * 2 * PI); + var direction = edge ? normalize(rock - centre) : float2(cos(angle), sin(angle)); + var from = rock + (edge ? 60f + (float) rng.NextDouble() * 240f : 1f + (float) rng.NextDouble() * 299f) * direction; + var reach = length(rock - from); + var candidates = new List(); + zone.Targets.Within(new TargetSearch(from, reach), candidates); + var found = candidates.Where(c => c.Target.Chunk.HasValue).Select(c => c.Target.Chunk.Value).ToList(); + Assert.Contains(new ChunkId(field, index), found); + var expected = s.Belts.SelectMany(b => Enumerable.Range(0, ((AsteroidBeltData) zone.Planets[b]).Asteroids.Length).Select(i => new ChunkId(b, i))) + .Where(c => zone.ChunkExists(c) && length(zone.ChunkPose(c.Field, c.Index).xy - from) <= reach).ToList(); + Assert.True(expected.ToHashSet().SetEquals(found) && expected.Count == found.Count, + $"step {step}, search {q}: {expected.Count} expected, {found.Count} found"); + searches++; + } + } + Assert.Equal(240, searches); + } +} diff --git a/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs b/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs new file mode 100644 index 000000000..5cfd6d72b --- /dev/null +++ b/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs @@ -0,0 +1,550 @@ +/* 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 at most 635 across the presses below, at both sizes). + private const long KeyPressExaminedCeiling = 700; + + // A belt and the observer: reach `reach`, with the dimmest sensor (found by doubling) that sees at least `share` + // of the rocks in its reach, so some are lit and some dark whatever the fixture's light. + private (Scene scene, Ship observer) TargetScene(int seed, float2 at, float reach = 120f, float share = .4f, 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))); + var inReach = AllChunks(s).Where(c => length(At(s, c) - at) <= reach).ToList(); + Assert.True(inReach.Count > 20, $"degenerate: {inReach.Count} rocks in reach"); + for (var sensitivity = .5f; sensitivity < 1e9f; sensitivity *= 2f) + { + var observer = Observer(s, at, reach, sensitivity, Falloff()); + if (inReach.Count(observer.ChunkVisible) >= share * inReach.Count) return (s, observer); + s.Zone.Entities.Remove(observer); + } + throw new InvalidOperationException("no sensitivity sees the rocks"); + } + + // 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; + // From nothing, nearest first stops at the first region no nearer rock can lie in: a fraction of the rocks in reach. + if (!held && !reticle) + { + var inReach = AllChunks(s).Count(c => length(At(s, c) - at) <= 150f); + Assert.True(cost * 4 < inReach, $"Next from nothing examined {cost} of {inReach} rocks in reach"); + } + if (!reticle) Assert.Equal(order[held ? order.Count / 2 + 1 : 0], observer.Target.Value); + return cost; + } + var report = new List(); + var grew = false; + var over = false; + 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); + report.Add($"held {held} reticle {reticle}: {small} at 30k, {large} at 300k"); + grew |= !(large < 2 * small && small < 2 * large); + over |= large >= KeyPressExaminedCeiling; + } + Assert.False(grew || over, $"examined rocks per press, ceiling {KeyPressExaminedCeiling}: {string.Join("; ", report)}"); + } + + // The pruning of every press, pinned by what a press examines. Each ceiling sits within about 10% of the count + // measured at this commit (the same fixture, fixed seed, one density of 100 square units per rock); a press that + // loses its angular bound (the reticle), its held-target prune (Next and Previous from a rock at the median + // distance) or its angle wrap examines 15% to 55% more than that, and fails. The belt's size and the reach vary: + // 30k rocks at reach 150, 300k at reach 150, and 300k at reach 400 with some 5000 rocks in reach. + [Fact] + public void PressPruningKeepsEachPressNearItsMeasuredCost() + { + var kind = Kind("Asteroid", 4f); + var failures = new List(); + foreach (var (count, ring, reaches) in new[] { (30_000, 2100f, new[] { 150f }), (300_000, 2800f, new[] { 150f, 400f }) }) + { + 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 = ring * float2(cos(1f), sin(1f)); + foreach (var reach in reaches) + { + var observer = Observer(s, at, reach, 3f, Falloff()); + var order = ByDistance(s, observer, reach); + Assert.True(order.Count > 500, $"degenerate: {order.Count} candidates at reach {reach}"); + var median = order[order.Count / 2]; + long Cost(Func press, TargetRef held) + { + observer.SetTarget(held); + var before = s.Zone.Targets.Examined; + Assert.True(press()); + return s.Zone.Targets.Examined - before; + } + void Check(string press, long examined, long ceiling) + { + if (examined > ceiling) failures.Add($"{count} rocks, reach {reach}, {press}: examined {examined}, ceiling {ceiling}"); + } + var (nextNone, nextHeld, previousNone, previousHeld, reticle) = (count, reach) switch + { + (30_000, _) => (100L, 575L, 290L, 590L, 620L), + (_, 150f) => (40L, 560L, 460L, 560L, 680L), + _ => (120L, 4700L, 4600L, 4700L, 5900L) + }; + Check("Next from nothing", Cost(observer.TargetNext, TargetRef.None), nextNone); + Check("Next from the median rock", Cost(observer.TargetNext, median), nextHeld); + Check("Previous from nothing", Cost(observer.TargetPrevious, TargetRef.None), previousNone); + Check("Previous from the median rock", Cost(observer.TargetPrevious, median), previousHeld); + foreach (var heading in new[] { 0f, 2f, 4f }) + { + observer.LookDirection = float3(cos(heading), 0, sin(heading)); + Check($"the reticle at {heading}", Cost(observer.TargetUnderReticle, TargetRef.None), reticle); + } + s.Zone.Entities.Remove(observer); + } + } + Assert.True(failures.Count == 0, string.Join("; ", failures)); + } + + // Rocks at one distance are ordered by field key, then index, across belts: four rocks on one spot, two in each + // of two belts, are walked in exactly that order by Next and, reversed, by Previous. + [Fact] + public void RocksAtOneDistanceAreOrderedByFieldThenIndex() + { + var kind = Kind("Asteroid", 40f); + var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f), Rock(150f)), Belt(kind, Rock(150f), Rock(150f))); + var eyeAt = float2(150, -100); + var observer = SpawnShip(s, eyeAt, sensor: true, weaponRanges: new[] { 300f }); + Tick((observer, eyeAt)); + var expected = s.Belts.OrderBy(b => b.Value, StringComparer.Ordinal) + .SelectMany(b => new[] { new TargetRef(new ChunkId(b, 0)), new TargetRef(new ChunkId(b, 1)) }).ToList(); + Assert.All(expected, t => Assert.True(observer.ChunkVisible(t.Chunk.Value))); + Assert.Equal(expected, ByDistance(s, observer, 300f)); + for (var i = 0; i < 4; i++) + { + Assert.True(observer.TargetNext()); + Assert.Equal(expected[i], observer.Target.Value); + } + observer.SetTarget(TargetRef.None); + for (var i = 3; i >= 0; i--) + { + Assert.True(observer.TargetPrevious()); + Assert.Equal(expected[i], observer.Target.Value); + } + } + + + // 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); + 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"); + // It may see the other ship; it must not pick a rock. + foreach (var press in new Func[] { ship.TargetUnderReticle, ship.TargetNext, ship.TargetPrevious }) + { + ship.SetTarget(TargetRef.None); + press(); + Assert.False(ship.Target.Value.Chunk.HasValue, "a launcher-only ship picked a rock"); + } + } + + // 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); + } + + // A ship in the zone that the observer has not detected: it is in Zone.Entities and nowhere in VisibleEntities. + private static Ship Unseen(Scene s, float2 at) + { + var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings()); + s.Zone.Entities.Add(ship); + Hold(ship, at); + return ship; + } + + // The pilot's instruments bound the pick: a ship the observer has not detected is never offered, whichever end of + // the order or look direction it would win. Unseen ships sit nearest, farthest, and dead ahead. + [Fact] + public void ShipsTheObserverHasNotDetectedAreNeverPicked() + { + var at = float2(500, 0); + var (s, observer) = TargetScene(7, at); + Sight(s, observer, at + float2(60, 0)); + Sight(s, observer, at + float2(0, -900)); + var hidden = new TargetRef[] { Unseen(s, at + float2(0, 5)), Unseen(s, at + float2(0, 3000)), Unseen(s, at + float2(0, 40)) }; + var order = ByDistance(s, observer, 120f); + Assert.True(order.Count > 5 && order.Count(t => t.Entity != null) == 2, $"degenerate: {order.Count} candidates"); + Assert.All(hidden, h => Assert.DoesNotContain(h, order)); + + 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); + } + 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); + } + // Looking straight at a hidden ship (heading 0 is dead ahead of the nearest) and in every other direction: a + // visible target, never a hidden one. + for (var heading = 0f; heading < 2 * PI; heading += .7f) + { + observer.LookDirection = float3(sin(heading), 0, cos(heading)); + observer.SetTarget(TargetRef.None); + Assert.True(observer.TargetUnderReticle()); + Assert.DoesNotContain(observer.Target.Value, hidden); + Assert.Contains(observer.Target.Value, order); + } + } + + // Candidates at one distance are ordered entity before chunk, then entities by zone order and chunks by field key + // then index; candidates at one bearing tie the same way for the reticle. A ship stands on the rocks' spot, so + // all five tie exactly in distance and in angle. + [Fact] + public void AShipOnTheRocksSpotOrdersBeforeThem() + { + var kind = Kind("Asteroid", 40f); + var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f), Rock(150f)), Belt(kind, Rock(150f), Rock(150f))); + var eyeAt = float2(150, -100); + var observer = SpawnShip(s, eyeAt, sensor: true, weaponRanges: new[] { 300f }); + Tick((observer, eyeAt)); + var chunks = s.Belts.OrderBy(b => b.Value, StringComparer.Ordinal) + .SelectMany(b => new[] { new TargetRef(new ChunkId(b, 0)), new TargetRef(new ChunkId(b, 1)) }).ToList(); + var spot = At(s, chunks[0].Chunk.Value); + var ship = Sight(s, observer, spot); + var expected = new List { new TargetRef(ship) }; + expected.AddRange(chunks); + Assert.All(chunks, t => Assert.Equal(spot, At(s, t.Chunk.Value))); + Assert.All(chunks, t => Assert.True(observer.ChunkVisible(t.Chunk.Value))); + Assert.Equal(expected, ByDistance(s, observer, 300f)); + + observer.SetTarget(TargetRef.None); + for (var i = 0; i < 5; i++) + { + Assert.True(observer.TargetNext()); + Assert.Equal(expected[i], observer.Target.Value); + } + observer.SetTarget(TargetRef.None); + for (var i = 4; i >= 0; i--) + { + Assert.True(observer.TargetPrevious()); + Assert.Equal(expected[i], observer.Target.Value); + } + for (var i = 0; i < 5; i++) + { + observer.SetTarget(expected[i]); + Assert.True(observer.TargetNext()); + Assert.Equal(expected[(i + 1) % 5], observer.Target.Value); + observer.SetTarget(expected[i]); + Assert.True(observer.TargetPrevious()); + Assert.Equal(expected[(i + 4) % 5], observer.Target.Value); + } + + // The reticle, looking at the spot: the ship; then with the ship gone from sight, the first chunk. + observer.LookDirection = float3(0, 0, 1); + observer.SetTarget(TargetRef.None); + Assert.True(observer.TargetUnderReticle()); + Assert.Equal(expected[0], observer.Target.Value); + observer.VisibleEntities.Remove(ship); + observer.SetTarget(TargetRef.None); + Assert.True(observer.TargetUnderReticle()); + Assert.Equal(expected[1], observer.Target.Value); + } + + // A press with nothing to offer returns false and leaves the target as it was: a ship with no sensor sees no rock + // and has no mining reach, and sees no ship, whether it holds a target or not. + [Fact] + public void APressWithNoCandidateReturnsFalseAndKeepsTheTarget() + { + var kind = Kind("Asteroid", 4f); + var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f), Rock(160f, .3f))); + var eyeAt = float2(150, -100); + var blind = SpawnShip(s, eyeAt); + var held = Unseen(s, float2(100, 0)); + foreach (var target in new[] { TargetRef.None, new TargetRef(held) }) + { + blind.SetTarget(target); + Assert.False(blind.TargetUnderReticle()); + Assert.False(blind.TargetNext()); + Assert.False(blind.TargetPrevious()); + Assert.False(blind.TargetNearestEnemy()); + Assert.Equal(target, blind.Target.Value); + } + } + + // Enemies at one distance: the first seen wins, whichever order they were seen in; the observer is never its own + // enemy, though it sits at distance zero. + [Fact] + public void EnemiesAtOneDistanceTieToTheFirstSeenAndNeverTheObserver() + { + var at = float2(500, 0); + var (s, observer) = TargetScene(7, at); + var a = Sight(s, observer, at + float2(0, 40), enemy: true); + var b = Sight(s, observer, at + float2(40, 0), enemy: true); + Assert.Equal(length(a.Position - observer.Position), length(b.Position - observer.Position)); + Assert.True(observer.TargetNearestEnemy()); + Assert.Equal(new TargetRef(a), observer.Target.Value); + + observer.VisibleEnemies.Clear(); + observer.VisibleEnemies.Add(b); + observer.VisibleEnemies.Add(a); + Assert.True(observer.TargetNearestEnemy()); + Assert.Equal(new TargetRef(b), observer.Target.Value); + + observer.VisibleEnemies.Clear(); + observer.VisibleEnemies.Add(observer); + observer.VisibleEnemies.Add(a); + Assert.True(observer.TargetNearestEnemy()); + Assert.Equal(new TargetRef(a), observer.Target.Value); + observer.VisibleEnemies.Remove(a); + observer.SetTarget(TargetRef.None); + Assert.False(observer.TargetNearestEnemy()); + Assert.True(observer.Target.Value.IsNone); + } + + // Ships between the mining reach and 250 keep their distance order for Previous: its key is the distance itself + // at every range, not a function that agrees with it only near and far. + [Fact] + public void ShipsBetweenReachAndTwoFiftyAreOrderedByDistanceBothWays() + { + var at = float2(500, 0); + var (s, observer) = TargetScene(7, at); + foreach (var offset in new[] { float2(135, 0), float2(0, 160), float2(-185, 0), float2(0, -215) }) Sight(s, observer, at + offset); + var order = ByDistance(s, observer, 120f); + Assert.True(order.Count(t => t.Chunk.HasValue) >= 8 && order.Count(t => t.Entity != null) == 4, $"degenerate: {order.Count} candidates"); + Assert.All(order.Take(order.Count - 4), t => Assert.True(t.Chunk.HasValue)); + 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); + } + 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); + } + } +} diff --git a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs index ed7756a5a..328009b24 100644 --- a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs +++ b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs @@ -46,7 +46,7 @@ public sealed class RestoredHullsTests return cache; } - private static string FindRepoRoot() + internal static string FindRepoRoot() { for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent) if (File.Exists(Path.Combine(dir.FullName, "Aetheria.Shared", "Aetheria.Shared.csproj"))) @@ -59,7 +59,7 @@ public sealed class RestoredHullsTests // Shared tutorial-galaxy construction settings (Assets/Resources/Settings.asset's own values), factored out // so every test that needs a real tutorial Galaxy -- not just EntranceZoneAlwaysGetsADockedStationAcrossSeeds // -- builds it the same way instead of re-inlining the same ~20 fields. - private static ZoneGenerationSettings TutorialZoneSettings() => new ZoneGenerationSettings + internal static ZoneGenerationSettings TutorialZoneSettings() => new ZoneGenerationSettings { PlanetSafetyRadius = new ExponentialCurve { Exponent = .25f, Multiplier = 2.5f, Constant = 0 }, MassFloor = 1, SunMass = 5000, GasGiantMass = 1000, PlanetMass = 100, @@ -80,19 +80,19 @@ public sealed class RestoredHullsTests NameData = new string[0], }; - private static TutorialGenerationSettings TutorialGalaxySettings() => new TutorialGenerationSettings + internal static TutorialGenerationSettings TutorialGalaxySettings() => new TutorialGenerationSettings { ProtagonistFaction = "Miss", AntagonistFaction = "Zhe", BufferFaction = "Luc", NeutralFactions = new[] { "Aero", "Finch" }, QuestFaction = "Adras", LinkDensity = .5f, ZoneCount = 64, }; - private static SectorBackgroundSettings TutorialBackgroundSettings() => new SectorBackgroundSettings + internal static SectorBackgroundSettings TutorialBackgroundSettings() => new SectorBackgroundSettings { NoiseAmplitude = 1, NoiseOffset = .3f, NoiseGain = .7f, NoiseLacunarity = 2, NoiseFrequency = .1f, NoisePosition = 536.5106f, CloudExponent = 10, CloudAmplitude = .01f, }; - private static NameGeneratorSettings TutorialNameSettings() => + internal static NameGeneratorSettings TutorialNameSettings() => new NameGeneratorSettings { NameGeneratorMinLength = 5, NameGeneratorMaxLength = 10, NameGeneratorOrder = 3 }; // A station's orbit is a copy of its source planet's own (Parent, Distance) (ZoneGenerator.CreateLagrangeOrbit), diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index bfb71e59d..755dd91bb 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -332,59 +332,6 @@ public sealed partial class RunStartTests : IDisposable Assert.True(stalls.Count == 0, $"{stalls.Count} readings of an orbit standing still; first {stalls.FirstOrDefault()}"); } - // A belt's asteroids are laid out for one time: the time of the tick that started the belt's task. Were the - // clock advanced before that task finished, some asteroids would be placed for the next tick and some for this. - [Fact] - public void EveryAsteroidInABeltIsPlacedForTheSameTime() - { - var zone = Arena(false); // no ships: nothing but the zone runs - var (key, belt) = zone.AsteroidBelts.First(); - var asteroids = ((AsteroidBeltData) zone.Planets[key]).Asteroids; - const float dt = .02f; - float2 At(Asteroid asteroid, float time, float2 center) => - OrbitData.Evaluate((float) frac((double) time / zone.Settings.OrbitPeriod.Evaluate(asteroid.Distance) + asteroid.Phase)) * asteroid.Distance + center; - var torn = 0; - zone.Update(dt); - for (var tick = 0; tick < 3000; tick++) - { - var started = zone.Time; // the time the running task was started for - zone.Update(dt); // settles that task and publishes its layout - for (var i = 0; i < asteroids.Length; i++) - { - var placed = belt.Transforms[i].xy; - var here = At(asteroids[i], started, belt.OrbitPosition); - var next = At(asteroids[i], started + dt, belt.OrbitPosition); - if (lengthsq(next - here) < 1e-6f) continue; // too slow to tell the two times apart - if (lengthsq(placed - here) > lengthsq(placed - next)) torn++; - } - } - Assert.True(torn == 0, $"{torn} asteroid placements were for the next tick"); - } - - // Mining writes a belt's damage and respawn state, which the belt's task reads, so it waits for the task first. - // This drives mining hard while the belt updates and fails on any exception the race throws; the race itself is - // rare enough that removing the wait did not fail it in two runs, so it guards, but does not prove, the rule. - [Fact] - public void MiningWhileTheBeltUpdatesNeverRacesIt() - { - var zone = Arena(false); // no ships: nothing but the zone runs - var (key, _) = zone.AsteroidBelts.First(); - var data = (AsteroidBeltData) zone.Planets[key]; - var count = data.Asteroids.Length; - var miner = zone.Entities.First(); - for (var tick = 0; tick < 1000; tick++) - { - zone.Update(.02f); - for (var pass = 0; pass < 4; pass++) - { - // Chip every asteroid (the belt's damage table fills), then break every one (it empties again). - if (data.Resources.Count > 0) - for (var i = 0; i < count; i++) zone.MineAsteroid(miner, key, (i + tick) % count, 1e-3f, 0, 1); - for (var i = 0; i < count; i++) zone.MineAsteroid(miner, key, (i + tick) % count, 1e6f, 0, 1); - } - } - } - private LoadoutGenerator PreludeGenerator() => new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f); diff --git a/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs b/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs index 478bc93c2..29bb3f954 100644 --- a/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs +++ b/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs @@ -174,7 +174,7 @@ public sealed class TargetingSystemTests : IDisposable { var (items, target, weapon, _, _, _) = BuildTarget(TestSettings()); var observer = BuildObserver(items, target.Zone); - observer.Target.Value = target; + observer.SetTarget(target); observer.EntityInfoGathered[target] = .29f; // below the single item's own tier (Armor, N==1) Assert.False(observer.TrySelectTargetItem(weapon)); @@ -186,12 +186,12 @@ public sealed class TargetingSystemTests : IDisposable Assert.Same(weapon, observer.ResolvedTargetItem); // A Target change nulls the aim point. - observer.Target.Value = null; + observer.SetTarget(TargetRef.None); Assert.Null(observer.TargetItem.Value); // Decay: re-select, then let info fall back below tier without touching TargetItem.Value at all -- // the raw field still holds the item (no loop cleared it), but the read accessor drops it. - observer.Target.Value = target; + observer.SetTarget(target); Assert.True(observer.TrySelectTargetItem(weapon)); observer.EntityInfoGathered[target] = .1f; Assert.Same(weapon, observer.TargetItem.Value); diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 251494d7d..c65f4c3f2 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -37,8 +37,9 @@ public static class Program case "targeting-catalog-6c": return TargetingCatalog6c(args.Contains("apply")); case "targeting-catalog-6d": return TargetingCatalog6d(args.Contains("apply")); case "ship-authoring": return ShipAuthoringCommands.Run(args.Skip(1).ToArray()); + case "field-kinds": return FieldKindsCatalog(args.Contains("apply")); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply]"); return 1; } } @@ -1472,4 +1473,36 @@ public static class Program Console.WriteLine($"\nLanded {precisionChanges.Length} Precision changes in Aetheria.cc"); return 0; } + + // Mining Cut 3 (docs/mining-cut-refresh.md): the first field kind. A chunk is detected by reflected light, and + // its reflectivity comes from its belt's kind, so a catalog with no kind leaves every belt dark. This authors + // the one kind Cut 3 needs, "Asteroid"; Cut 7 authors the rest. CrossSection is reflectivity per schematic + // cell of chunk area: 5 sits inside the shipped hulls' 2-15 per cell (Longinus, Djinni), so a mid-size rock + // (3-6 units across, 7-28 cells) reflects like a small ship. Both numbers are for the operator to review. + private static int FieldKindsCatalog(bool apply) + { + var db = AetherDb.Open(catalogWritable: apply); + var kinds = db.Cache.GetAll().ToArray(); + foreach (var kind in kinds) + Console.WriteLine($" {kind.Name,-12} cross-section {kind.CrossSection,5:0.##} per cell, weight {kind.GenerationWeight:0.##}"); + + if (kinds.Any(kind => kind.Name == "Asteroid")) + { + Console.WriteLine("\"Asteroid\" is already authored; nothing to do."); + return 0; + } + + var asteroid = new FieldKindData { Name = "Asteroid", CrossSection = 5f, GenerationWeight = 1f }; + Console.WriteLine($"Adds \"Asteroid\": cross-section {asteroid.CrossSection} per cell, weight {asteroid.GenerationWeight}."); + if (!apply) + { + Console.WriteLine("\nDry run. Pass \"apply\" to author it."); + return 0; + } + + CultRecordRefs.Validate(asteroid); + db.Cache.Commit(batch => batch.Upsert(typeof(FieldKindData), asteroid)); + Console.WriteLine("\nAuthored \"Asteroid\" in Aetheria.cc"); + return 0; + } }