Merge eureka/aetheria-release-mining: the targeting index, best-first target queries, and the end of the belt freeze

Mining Cuts 1-3, the general targeting index (ships and belts), and next/previous/reticle as index queries on Entity, with master merged in and the catalog's field kinds rebuilt. Soul held mining-index-pins, mining-target-queries (two passes) and mining-merge; the Unity compile and both ships smokes pass. Mining yield (Cuts 4, 5, 7) is not in this merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-06 18:24:29 +02:00
commit df7c44f2e1
57 changed files with 6250 additions and 530 deletions

BIN
Assets/Resources/Settings.asset (Stored with Git LFS)

Binary file not shown.

View file

@ -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<Vector2>().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);

View file

@ -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);

View file

@ -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) };

View file

@ -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);
}
}

View file

@ -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();

View file

@ -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)),

View file

@ -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);
}

View file

@ -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;
}
}

View file

@ -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<Corporation>(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<Corporation>(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);
}
}

View file

@ -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);
}
}

View file

@ -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;
}

View file

@ -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

View file

@ -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)
{

View file

@ -43,7 +43,10 @@ public abstract class Entity
public Entity Parent;
public List<Entity> Children = new List<Entity>();
public ReactiveProperty<Entity> Target = new ReactiveProperty<Entity>((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<TargetRef> _target = new ReactiveProperty<TargetRef>(TargetRef.None);
public IReadOnlyReactiveProperty<TargetRef> 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<ChunkId> 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>(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>(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<TargetCandidate> _targetCandidates = new List<TargetCandidate>();
private static readonly Comparison<ChunkId> 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<Sensor> ActiveSensors() =>
Equipment.Where(item => item.Active.Value).SelectMany(item => item.Behaviors).OfType<Sensor>()
.Concat(_activeConsumables.SelectMany(effect => effect.Behaviors).OfType<Sensor>());
// 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;

View file

@ -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

View file

@ -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;

View file

@ -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;

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7c083d5830b0418cbf8e34158465d764
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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<TargetRegion> 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<TargetCandidate> 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<TargetRegion> 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);
}
}
}

View file

@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: a7eebfaae57b2304580ef7e4a2740993
guid: d33fc585810446f7b0a9319196c74633
MonoImporter:
externalObjects: {}
serializedVersion: 2

View file

@ -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<TargetRegion> 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<TargetCandidate> into)
{
foreach (var entity in search.Observer.VisibleEntities)
into.Add(new TargetCandidate(entity, entity.Position.xz));
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1db47cd187504dbb97994e4b8c436ef1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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<TargetRegion> into);
// The things of one region this provider made for this search, with their positions now, added to `into`.
void Candidates(in TargetSearch search, in TargetRegion region, List<TargetCandidate> into);
}
// A best-first question: the candidate with the least key wins. Bound must never exceed the key of any candidate the
// region holds (positive infinity skips the region). Key returns false for a candidate that is not eligible.
// Precedes breaks a tie between two equal keys.
public interface ITargetKey
{
float Bound(in TargetRegion region);
bool Key(in TargetCandidate candidate, out float key);
bool Precedes(in TargetCandidate a, in TargetCandidate b);
}
public sealed class TargetingIndex
{
private readonly List<ITargetProvider> _providers = new List<ITargetProvider>();
private readonly List<TargetRegion> _regions = new List<TargetRegion>();
private readonly List<TargetCandidate> _candidates = new List<TargetCandidate>();
private readonly List<(float bound, int region)> _order = new List<(float, int)>();
// Diagnostic only: how many candidates the index has posed and tested, ever. Tests pin a query's cost with it
// rather than with timing.
public long Examined { get; private set; }
public void Add(ITargetProvider provider) => _providers.Add(provider);
// Every candidate within the search's reach whose region can be detected, written into `into` (cleared first),
// in no particular order. Reach is exact; detection is only bounded, so the caller still tests it.
public void Within(in TargetSearch search, List<TargetCandidate> into)
{
into.Clear();
_regions.Clear();
foreach (var provider in _providers) provider.Regions(search, _regions);
foreach (var region in _regions)
{
if (!region.CanHold(search)) continue;
_candidates.Clear();
region.Provider.Candidates(search, region, _candidates);
foreach (var candidate in _candidates)
{
Examined++;
if (length(candidate.Position - search.Position) <= search.Reach) into.Add(candidate);
}
}
}
// The eligible candidate within reach with the least key, or false if there is none. Regions are opened in order
// of their bound, and the search stops at the first region whose bound is greater than the best key found:
// Hjaltason and Samet's best-first browsing over the providers' flat region sets.
public bool Best<TKey>(in TargetSearch search, TKey query, out TargetCandidate best) where TKey : ITargetKey
{
best = default;
var found = false;
var bestKey = float.PositiveInfinity;
_regions.Clear();
_order.Clear();
foreach (var provider in _providers) provider.Regions(search, _regions);
for (var i = 0; i < _regions.Count; i++)
{
if (!_regions[i].CanHold(search)) continue;
var bound = query.Bound(_regions[i]);
if (!float.IsPositiveInfinity(bound)) _order.Add((bound, i));
}
_order.Sort((a, b) => a.bound.CompareTo(b.bound));
foreach (var (bound, index) in _order)
{
if (found && bound > bestKey) break;
_candidates.Clear();
var region = _regions[index];
region.Provider.Candidates(search, region, _candidates);
foreach (var candidate in _candidates)
{
Examined++;
if (!(length(candidate.Position - search.Position) <= search.Reach) || !query.Key(candidate, out var key)) continue;
if (!found || key < bestKey || key == bestKey && query.Precedes(candidate, best))
{
best = candidate;
bestKey = key;
found = true;
}
}
}
return found;
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9fef7cc648f2498ebd24e1d0ca61b5c3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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<CultRecordKey, AsteroidBelt> AsteroidBelts = new Dictionary<CultRecordKey, AsteroidBelt>();
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<Sun> _suns = new List<Sun>();
private HashSet<CultRecordKey> _updatedOrbits = new HashSet<CultRecordKey>();
private ItemManager _itemManager;
private double _time;
private Random _random;
public List<Agent> Agents = new List<Agent>();
// 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<ChunkId, ChunkWear> _wear = new Dictionary<ChunkId, ChunkWear>();
// 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<Task> BeltUpdates = new List<Task>();
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<OrbitData>(key)).ToList(),
Planets = Planets.Keys.Select(key => new CultRecordRef<BodyData>(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<float4> 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<Sun> 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<int, float> RespawnTimers = new Dictionary<int, float>();
public Dictionary<int, float> Damage = new Dictionary<int, float>();
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<ChunkId>
{
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<TargetRef>
{
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

View file

@ -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<ChunkWearPack> 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<FieldKindData> 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<FieldKindData> Assign(CultRecordKey field, CultCache catalog)
{
var kinds = catalog.GetAll<FieldKindData>()
.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<FieldKindData>(kind.Key);
pick -= kind.Weight;
}
return new CultRecordRef<FieldKindData>(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)]

View file

@ -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();

View file

@ -72,6 +72,9 @@ public class ZoneRenderer : MonoBehaviour
private Dictionary<CultRecordKey, AsteroidBeltUI> _beltObjects = new Dictionary<CultRecordKey, AsteroidBeltUI>();
private Dictionary<CultRecordKey, InstancedMesh[]> _beltMeshes = new Dictionary<CultRecordKey, InstancedMesh[]>();
private Dictionary<CultRecordKey, Matrix4x4[][]> _beltMatrices = new Dictionary<CultRecordKey, Matrix4x4[][]>();
// 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<CultRecordKey, float4[]> _beltTransforms = new Dictionary<CultRecordKey, float4[]>();
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];

BIN
GameData/Aetheria.cc (Stored with Git LFS)

Binary file not shown.

940
docs/mining-cut-refresh.md Normal file
View file

@ -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 <aetheria clone> <merge sha> dotnet '<clone CultLib 45c2f40 to /CultLib and 6d5e209 to /CultMath; restore the two LFS files; dotnet test tests/Aetheria.Shared.Tests -p:CultLibRoot=/CultLib -p:CultMathRoot=/CultMath>'`.
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<object, float>`). 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<FieldKindData> 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<ChunkId> 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<TargetRef>`, 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<Entity> 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<Entity> 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<FieldYield>`. Each `FieldYield` is:
- `Commodity`: a `CultRecordRef<SimpleCommodityData>` (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<BodyData>`, 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<CraftedItemInstance>()` 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.

1081
docs/mining-cut.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -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);

View file

@ -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;

View file

@ -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);

View file

@ -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<ShotOutcome>();

View file

@ -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);

View file

@ -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);

View file

@ -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;

View file

@ -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);

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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.

View file

@ -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);

View file

@ -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<OrbitData>(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<OrbitData>(beltOrbitKey), Asteroids = asteroids };
var beltKey = cache.Upsert(beltData).Key;
var pack = new ZonePack
{
Orbits = { new CultRecordRef<OrbitData>(rootKey), new CultRecordRef<OrbitData>(beltOrbitKey) },
Planets = { new CultRecordRef<BodyData>(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);
}
}

View file

@ -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<OrbitData>(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<OrbitData>(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<OrbitData>(rootKey) }).Key;
var beltAData = (AsteroidBeltData) cache.Get(beltAKey);
var beltBData = (AsteroidBeltData) cache.Get(beltBKey);
var pack = new ZonePack
{
Orbits = { new CultRecordRef<OrbitData>(rootKey) },
Planets = { new CultRecordRef<BodyData>(beltAKey), new CultRecordRef<BodyData>(beltBKey), new CultRecordRef<BodyData>(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<OrbitData>(beltAData.Orbit.Key));
pack.Orbits.Add(new CultRecordRef<OrbitData>(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<BodyData>(f.BeltA), new CultRecordRef<BodyData>(f.BeltB) },
Orbits = f.Zone.Orbits.Keys.ConvertToRefList(),
Entities = new List<EntityPack>(),
Radius = 5000f,
Mass = 10000f,
Time = 12.5
};
var buffer = new ArrayBufferWriter<byte>();
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<ZonePack>(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<CultRecordRef<OrbitData>> ConvertToRefList(this IEnumerable<CultRecordKey> keys)
{
var list = new List<CultRecordRef<OrbitData>>();
foreach (var key in keys) list.Add(new CultRecordRef<OrbitData>(key));
return list;
}
}

File diff suppressed because it is too large Load diff

View file

@ -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<ChunkId> into)
{
var candidates = new List<TargetCandidate>();
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<ChunkId> 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<ChunkId>();
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<TargetRegion>();
var candidates = new List<TargetCandidate>();
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<ChunkId>();
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<ChunkId>();
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<ChunkId>();
var before = s.Zone.Targets.Examined;
observer.VisibleChunksInReach(offered);
Assert.Equal(0, s.Zone.Targets.Examined - before);
Assert.Empty(offered);
var found = new List<ChunkId>();
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<TargetRegion>();
// 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<ChunkId>();
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<TargetCandidate>();
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<TargetCandidate>();
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<TargetRegion>();
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<ChunkId>();
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<TargetCandidate>();
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);
}
}

View file

@ -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<TargetRef> ByDistance(Scene s, Ship observer, float reach)
{
var at = observer.Position.xz;
var all = new List<(TargetRef target, float distance)>();
foreach (var e in observer.VisibleEntities)
if (e != observer) all.Add((e, length(e.Position.xz - at)));
foreach (var c in AllChunks(s))
if (s.Zone.ChunkExists(c) && length(At(s, c) - at) <= reach && observer.ChunkVisible(c))
all.Add((c, length(At(s, c) - at)));
return all.OrderBy(x => x.distance)
.ThenBy(x => x.target.Chunk.HasValue ? 1 : 0)
.ThenBy(x => x.target.Chunk.HasValue ? 0 : s.Zone.Entities.IndexOf(x.target.Entity))
.ThenBy(x => x.target.Chunk.HasValue ? x.target.Chunk.Value.Field.Value : "", StringComparer.Ordinal)
.ThenBy(x => x.target.Chunk.HasValue ? x.target.Chunk.Value.Index : 0)
.Select(x => x.target).ToList();
}
private static void SightShips(Scene s, Ship observer, float2 at)
{
// Three ships at one distance (a tie), one past mining reach, one far beyond it.
Sight(s, observer, at + float2(40, 0));
Sight(s, observer, at + float2(0, 40));
Sight(s, observer, at + float2(-40, 0));
Sight(s, observer, at + float2(250, 0));
Sight(s, observer, at + float2(0, -900));
}
// Repeated Next from nothing visits every target in the scan's order and wraps; Previous is the reverse. Ties
// between ships at one distance, ships past reach, and rocks both near and at reach are all in the order.
[Fact]
public void NextAndPreviousWalkTheBruteForceOrder()
{
var walked = 0;
foreach (var (seed, at) in new[] { (7, float2(500, 0)), (8, float2(-350, 400)), (9, float2(0, -650)) })
{
var (s, observer) = TargetScene(seed, at);
SightShips(s, observer, at);
var order = ByDistance(s, observer, 120f);
Assert.True(order.Count(t => t.Chunk.HasValue) >= 8 && order.Count(t => t.Entity != null) == 5, $"degenerate: {order.Count} candidates");
observer.SetTarget(TargetRef.None);
for (var i = 0; i <= order.Count; i++)
{
Assert.True(observer.TargetNext());
Assert.Equal(order[i % order.Count], observer.Target.Value);
walked++;
}
observer.SetTarget(TargetRef.None);
for (var i = 0; i <= order.Count; i++)
{
Assert.True(observer.TargetPrevious());
Assert.Equal(order[(order.Count - 1 - i % order.Count)], observer.Target.Value);
walked++;
}
// From every target, Next is its successor and Previous its predecessor.
for (var i = 0; i < order.Count; i++)
{
observer.SetTarget(order[i]);
observer.TargetNext();
Assert.Equal(order[(i + 1) % order.Count], observer.Target.Value);
observer.SetTarget(order[i]);
observer.TargetPrevious();
Assert.Equal(order[(i + order.Count - 1) % order.Count], observer.Target.Value);
}
}
Assert.True(walked > 60, $"degenerate: {walked} steps");
}
// Operator, "Fix the off-by-one": with nothing targeted, Previous takes the farthest candidate, not the one
// before it.
[Fact]
public void PreviousWithNoTargetPicksTheFarthest()
{
var at = float2(500, 0);
var (s, observer) = TargetScene(7, at);
Sight(s, observer, at + float2(60, 0));
var order = ByDistance(s, observer, 120f);
Assert.True(order.Count > 3);
Assert.True(order[^1].Chunk.HasValue, "the farthest should be a rock here");
Assert.True(observer.TargetPrevious());
Assert.Equal(order[^1], observer.Target.Value);
Assert.NotEqual(order[^2], observer.Target.Value);
// With a far ship as well, the farthest is the ship.
var far = Sight(s, observer, at + float2(0, -900));
observer.SetTarget(TargetRef.None);
Assert.True(observer.TargetPrevious());
Assert.Equal(new TargetRef(far), observer.Target.Value);
}
// The reticle's pick is the scan's least planar angle, over ships and rocks alike, in many look directions; the
// same press again clears the target.
[Fact]
public void TheReticlePicksTheSmallestAngle()
{
var at = float2(-350, 400);
var (s, observer) = TargetScene(8, at);
SightShips(s, observer, at);
var order = ByDistance(s, observer, 120f);
Assert.True(order.Count > 15);
float2 Where(TargetRef t) => t.Chunk is ChunkId c ? At(s, c) : t.Entity.Position.xz;
var rng = new System.Random(3);
var picked = new HashSet<TargetRef>();
for (var i = 0; i < 60; i++)
{
var heading = rng.NextDouble() * 2 * Math.PI;
observer.LookDirection = float3((float) Math.Cos(heading), 0, (float) Math.Sin(heading));
double Angle(TargetRef t)
{
var to = Where(t) - at;
return Math.Abs(Math.Atan2((double) Math.Cos(heading) * to.y - Math.Sin(heading) * to.x,
Math.Cos(heading) * to.x + Math.Sin(heading) * to.y));
}
var expected = order.OrderBy(Angle).First();
observer.SetTarget(TargetRef.None);
Assert.True(observer.TargetUnderReticle());
Assert.Equal(expected, observer.Target.Value);
picked.Add(expected);
Assert.True(observer.TargetUnderReticle());
Assert.True(observer.Target.Value.IsNone, "the second press on the same target clears it");
}
Assert.True(picked.Count > 10, $"degenerate: {picked.Count} distinct picks");
}
// The operator's rule for F1: a key press does not grow with the belt. TargetNext, from nothing and from a held
// rock, at 30k and 300k rocks of one density, examines about as many in both, under a fixed ceiling.
// Measured at this commit: see ExaminedAtPress.
[Fact]
public void AKeyPressDoesNotGrowWithTheBelt()
{
var kind = Kind("Asteroid", 4f);
long Press(int count, bool held, bool reticle)
{
var outer = sqrt(2000f * 2000f + count * 100f / PI);
var s = BuildSceneAt(float2.zero, 20000f, 0, null, new[] { kind }, Belt(kind, Annulus(new System.Random(21), count, 2000f, outer)));
var at = 2100f * float2(cos(1f), sin(1f));
var observer = Observer(s, at, 150f, 3f, Falloff());
observer.LookDirection = float3(1, 0, 0);
var order = ByDistance(s, observer, 150f);
Assert.True(order.Count > 5);
if (held) observer.SetTarget(order[order.Count / 2]);
var before = s.Zone.Targets.Examined;
Assert.True(reticle ? observer.TargetUnderReticle() : observer.TargetNext());
var cost = s.Zone.Targets.Examined - before;
// 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<string>();
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<string>();
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<bool> 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<ChunkId>();
var rng = new System.Random(5);
for (var i = 0; i < 200; i++)
{
var heading = rng.NextDouble() * 2 * Math.PI;
observer.LookDirection = float3((float) Math.Cos(heading), 0, (float) Math.Sin(heading));
observer.SetTarget(TargetRef.None);
switch (i % 3)
{
case 0: observer.TargetUnderReticle(); break;
case 1: observer.TargetNext(); observer.TargetNext(); break;
default: observer.TargetPrevious(); observer.TargetPrevious(); break;
}
if (observer.Target.Value.Chunk is ChunkId chunk)
{
Assert.Contains(chunk, visible);
picks.Add(chunk);
}
}
Assert.True(picks.Count > 3, $"degenerate: {picks.Count} distinct rocks picked");
// A launcher's longer range is no reach: a ship fitted with one alone picks no rock.
var launcher = Gear("Launcher", new LockWeaponData { Range = Constant(400f), LockSpeed = Constant(.5f), LockAngle = Constant(30f), DirectionImpact = Constant(1f) });
s.Cache.Upsert(launcher);
var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings());
Assert.True(ship.TryEquip(Mint(s, s.Eye)));
Assert.True(ship.TryEquip(Mint(s, launcher)));
s.Zone.Entities.Add(ship);
Hold(ship, at);
ship.LookDirection = float3(0, 0, 1);
ship.Activate();
Tick((ship, at));
Assert.Contains(ship.Weapons, w => w.Range == 400f);
Assert.True(AllChunks(s).Any(c => length(At(s, c) - at) <= 400f && ship.ChunkVisible(c)), "there are rocks within the launcher's range");
// It may see the other ship; it must not pick a rock.
foreach (var press in new Func<bool>[] { 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<TargetRef> { 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);
}
}
}

View file

@ -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),

View file

@ -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);

View file

@ -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);

View file

@ -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 <Type.Member>]... [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 <Type.Member>]... [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<FieldKindData>().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;
}
}