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:
commit
df7c44f2e1
57 changed files with 6250 additions and 530 deletions
BIN
Assets/Resources/Settings.asset
(Stored with Git LFS)
BIN
Assets/Resources/Settings.asset
(Stored with Git LFS)
Binary file not shown.
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
8
Assets/Scripts/ServerShared/Targeting.meta
Normal file
8
Assets/Scripts/ServerShared/Targeting.meta
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 7c083d5830b0418cbf8e34158465d764
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
300
Assets/Scripts/ServerShared/Targeting/BeltTargets.cs
Normal file
300
Assets/Scripts/ServerShared/Targeting/BeltTargets.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
fileFormatVersion: 2
|
||||
guid: a7eebfaae57b2304580ef7e4a2740993
|
||||
guid: d33fc585810446f7b0a9319196c74633
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
39
Assets/Scripts/ServerShared/Targeting/EntityTargets.cs
Normal file
39
Assets/Scripts/ServerShared/Targeting/EntityTargets.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/ServerShared/Targeting/EntityTargets.cs.meta
Normal file
11
Assets/Scripts/ServerShared/Targeting/EntityTargets.cs.meta
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 1db47cd187504dbb97994e4b8c436ef1
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
155
Assets/Scripts/ServerShared/Targeting/TargetingIndex.cs
Normal file
155
Assets/Scripts/ServerShared/Targeting/TargetingIndex.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/ServerShared/Targeting/TargetingIndex.cs.meta
Normal file
11
Assets/Scripts/ServerShared/Targeting/TargetingIndex.cs.meta
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 9fef7cc648f2498ebd24e1d0ca61b5c3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
BIN
GameData/Aetheria.cc
(Stored with Git LFS)
Binary file not shown.
940
docs/mining-cut-refresh.md
Normal file
940
docs/mining-cut-refresh.md
Normal 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
1081
docs/mining-cut.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
262
tests/Aetheria.Shared.Tests/MiningCut1Tests.cs
Normal file
262
tests/Aetheria.Shared.Tests/MiningCut1Tests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
523
tests/Aetheria.Shared.Tests/MiningCut2Tests.cs
Normal file
523
tests/Aetheria.Shared.Tests/MiningCut2Tests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
1022
tests/Aetheria.Shared.Tests/MiningCut3Tests.cs
Normal file
1022
tests/Aetheria.Shared.Tests/MiningCut3Tests.cs
Normal file
File diff suppressed because it is too large
Load diff
493
tests/Aetheria.Shared.Tests/MiningIndexTests.cs
Normal file
493
tests/Aetheria.Shared.Tests/MiningIndexTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
550
tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs
Normal file
550
tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue