From 0f26c4bb35d088ff3b81a85d94c0f05b2764592f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 17:20:52 +0200 Subject: [PATCH 01/12] Suns never take a green hue: draw sun hues from the circle minus a Settings band Primary hue maps through ColorMath.HueOutsideBand; the secondary takes the out-of-band side; fog tint and light derive from primary. Band edges are SunHueExcludedStart/End (75 and 165 degrees by default). Not yet verified on Yggdrasil at this commit. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ColorMath.cs | 6 + Assets/Scripts/ServerShared/Settings.cs | 3 + Assets/Scripts/ServerShared/ZoneGenerator.cs | 7 +- .../SunsNeverGreenTests.cs | 142 ++++++++++++++++++ 4 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs diff --git a/Assets/Scripts/ServerShared/ColorMath.cs b/Assets/Scripts/ServerShared/ColorMath.cs index b94764bd3..26ea704a7 100644 --- a/Assets/Scripts/ServerShared/ColorMath.cs +++ b/Assets/Scripts/ServerShared/ColorMath.cs @@ -18,6 +18,12 @@ public static class ColorMath return saturate(float3(r,g,b)); } + // A hue band on the circle runs from start up to end, wrapping past 1 when end < start. + public static bool HueInBand(float hue, float start, float end) => frac(hue - start) < frac(end - start); + + // Maps u in [0,1) onto the hues outside the band, so a uniform draw stays uniform over the allowed arc. + public static float HueOutsideBand(float u, float start, float end) => frac(end + u * (1 - frac(end - start))); + public static float3 HsvToRgb(in float3 hsv) { float3 rgb = HueToRgb(hsv.x); diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index cda5e1623..af22d2840 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -142,6 +142,9 @@ public class ZoneGenerationSettings public float SunSecondaryColorDistance = .25f; public float SunLightSaturation = .5f; public float SunFogTintSaturation = .5f; + // The green band no sun colour may take, as fractions of the hue circle (75 to 165 degrees: chartreuse through spring green). + public float SunHueExcludedStart = 75f / 360f; + public float SunHueExcludedEnd = 165f / 360f; public ExponentialLerp GasGiantBandCount; public float GasGiantBandColorSeparation = .25f; diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index 202e5e4e9..12a8810e9 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -159,8 +159,11 @@ public static class ZoneGenerator { if (gas is SunData sun) { - float primary = random.NextFloat(); - float secondary = frac(primary + 1 + zoneSettings.SunSecondaryColorDistance * (random.NextFloat() > .5 ? 1 : -1)); + float primary = ColorMath.HueOutsideBand(random.NextFloat(), zoneSettings.SunHueExcludedStart, zoneSettings.SunHueExcludedEnd); + float step = zoneSettings.SunSecondaryColorDistance * (random.NextFloat() > .5 ? 1 : -1); + float secondary = frac(primary + 1 + step); + if (ColorMath.HueInBand(secondary, zoneSettings.SunHueExcludedStart, zoneSettings.SunHueExcludedEnd)) + secondary = frac(primary + 1 - step); gas.Colors = new [] { float4(ColorMath.HsvToRgb(float3(primary, zoneSettings.SunColorSaturation, .5f)), 0), diff --git a/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs b/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs new file mode 100644 index 000000000..e94deab92 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using Xunit; + +// Operator rulings suns-never-green and suns-never-green-edges: no generated sun colour (the body's two colour stops, its +// fog tint and its light colour) takes a hue where green dominates. The band is the Settings default, 75 to 165 degrees; +// yellow (60) and teal (170) stay allowed. Hues are recovered from the RGB the production generator wrote. +public sealed class SunsNeverGreenTests : IDisposable +{ + private const float Degree = 1f / 360f; + // Hue recovered from a float RGB wobbles by a few millionths of a turn; the band is judged with this much margin. + private const float Slack = 1e-3f; + + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-sunsgreen-" + Guid.NewGuid().ToString("N")); + private int _runs; + private readonly List _caches = new List(); + + public SunsNeverGreenTests() => Directory.CreateDirectory(_root); + + public void Dispose() + { + foreach (var cache in _caches) cache.Dispose(); + Directory.Delete(_root, true); + } + + [Theory] + [InlineData(76f)] + [InlineData(80f)] + [InlineData(120f)] + [InlineData(160f)] + [InlineData(164f)] + public void HuesInsideTheDefaultBandAreRefused(float degrees) + { + var settings = new ZoneGenerationSettings(); + Assert.True(ColorMath.HueInBand(degrees * Degree, settings.SunHueExcludedStart, settings.SunHueExcludedEnd)); + } + + [Theory] + [InlineData(0f)] + [InlineData(60f)] + [InlineData(74f)] + [InlineData(166f)] + [InlineData(170f)] + [InlineData(180f)] + [InlineData(300f)] + public void YellowTealAndTheRestOfTheCircleAreAllowed(float degrees) + { + var settings = new ZoneGenerationSettings(); + Assert.False(ColorMath.HueInBand(degrees * Degree, settings.SunHueExcludedStart, settings.SunHueExcludedEnd)); + } + + [Fact] + public void TheDrawMapsOntoTheWholeAllowedArcAndNothingElse() + { + var settings = new ZoneGenerationSettings(); + var start = settings.SunHueExcludedStart; + var end = settings.SunHueExcludedEnd; + var low = float.MaxValue; + var high = float.MinValue; + for (var i = 0; i < 1000; i++) + { + var hue = ColorMath.HueOutsideBand(i / 1000f, start, end); + Assert.False(ColorMath.HueInBand(hue, start, end), $"draw {i}/1000 landed in the band"); + // Unwrapped from the band's far edge, the allowed arc runs 165 up to 75 + 360. + var along = ColorMath.HueInBand(hue, 0f, end) ? hue + 1f : hue; + low = Math.Min(low, along); + high = Math.Max(high, along); + } + Assert.InRange(low, end, end + 1f * Degree); + Assert.InRange(high, 1f + start - 1f * Degree, 1f + start); + } + + [Fact] + public void GeneratedSunsKeepEveryColourOutOfTheDefaultBand() + { + var settings = new ZoneGenerationSettings(); + var suns = GenerateSuns(z => { }, out _); + Assert.True(suns.Count >= 10, $"only {suns.Count} suns generated"); + foreach (var sun in suns) + foreach (var hue in Hues(sun)) + Assert.False(ColorMath.HueInBand(hue, settings.SunHueExcludedStart + Slack, settings.SunHueExcludedEnd - Slack), + $"a sun colour fell in the green band (hue {hue / Degree:F1} degrees)"); + } + + [Fact] + public void TheBandComesFromSettingsNotFromTheGenerator() + { + // A blue band, 200 to 300 degrees: suns avoid it and now take green freely. + var suns = GenerateSuns(z => + { + z.SunHueExcludedStart = 200f * Degree; + z.SunHueExcludedEnd = 300f * Degree; + }, out _); + Assert.True(suns.Count >= 10, $"only {suns.Count} suns generated"); + var hues = suns.SelectMany(Hues).ToList(); + Assert.All(hues, hue => Assert.False(ColorMath.HueInBand(hue, 200f * Degree + Slack, 300f * Degree - Slack))); + Assert.Contains(hues, hue => ColorMath.HueInBand(hue, 75f * Degree, 165f * Degree)); + } + + private static IEnumerable Hues(SunData sun) => + sun.Colors.Select(c => c.xyz) + .Concat(new[] { sun.FogTintColor, sun.LightColor }) + .Select(rgb => ColorMath.RgbToHsv(rgb).x); + + // Production path: Galaxy, ItemManager and ZoneGenerator.GenerateZone over the shipped ZoneSettings, as in OrbitsFromFieldTests. + private List GenerateSuns(Action adjust, out int zones) + { + var suns = new List(); + zones = 0; + _runs++; + for (uint seed = 1; seed <= 8; seed++) + { + var dir = Path.Combine(_root, $"galaxy{_runs}-{seed}"); + Directory.CreateDirectory(dir); + var catalog = Path.Combine(dir, "Aetheria.cc"); + File.Copy(TestCatalog.Repo, catalog); + var cache = new CultCache(TestCatalog.Registry()); + _caches.Add(cache); + cache.AddBackingStore(new GameCult.Caching.MessagePack.SingleFileMessagePackBackingStore(catalog), AetheriaStores.CatalogTypes); + cache.AddBackingStore(new GameCult.Caching.MessagePack.SingleFileMessagePackBackingStore(Path.Combine(dir, "run.cc")), AetheriaStores.RunTypes); + var authored = AuthoredSettings.Load(RunStartTests.FindRepoRoot()); + var tutorial = authored.Read("TutorialGenerationSettings"); + var galaxy = new Galaxy(tutorial, authored.Read("TutorialBackgroundSettings"), + authored.Read("NameGeneratorSettings"), cache, new PlayerSettings(), + Directory.CreateDirectory(Path.Combine(dir, "Narrative")), _ => { }, null, seed); + var items = new ItemManager(cache, new ProvenanceLedger(), authored.Read("GameplaySettings"), _ => { }); + var zoneSettings = authored.Read("ZoneSettings"); + adjust(zoneSettings); + foreach (var zone in galaxy.Zones) + { + zones++; + var pack = ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, zone, false, false); + suns.AddRange(pack.Planets.Select(p => cache.Get(p)).OfType()); + } + } + return suns; + } +} From fcf1b4688031e4676faf2fa9cc193b8ce0ba7fa6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 17:22:51 +0200 Subject: [PATCH 02/12] AI pilots vent on a Track paint past half and overdrive to close (consumable-ai r3) Re-land on master of the h1 cut with the s1 fixes: the vent rule reads one Track paint in the ship's own IncomingPaints, TryActivateConsumable refuses on an inactive ship, the AI holds while a consumable of the kind is active, and the top of the lock strength range is pinned. Not yet built or run. Co-Authored-By: Claude Opus 5.5 --- .../ServerShared/Agents/States/Combat.cs | 29 ++ Assets/Scripts/ServerShared/Entity.cs | 12 + .../ConsumableAiTests.cs | 344 ++++++++++++++++++ .../Aetheria.Shared.Tests/LockWarningTests.cs | 3 +- 4 files changed, 387 insertions(+), 1 deletion(-) create mode 100644 tests/Aetheria.Shared.Tests/ConsumableAiTests.cs diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index 3e143eb52..d7c20f275 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -10,6 +10,12 @@ using float2x2 = CultMath.float2x2; public class CombatState : BaseState { private const int DPS_SAMPLE_COUNT = 32; + // The Strength of one Track paint in this ship's own IncomingPaints: a fire-control emitter tracking this ship + // that hard (rulings lock-warning-strength, sensor-stat-set item 4, launcher-lock-solution-quality). + private const float LockAlarm = .5f; + // Overdrive only to close a target farther than this many optimum ranges, while heading within this many degrees of it. + private const float OverdriveRangeFactor = 2f; + private const float OverdriveArcDegrees = 15f; private float _optimumRange; private readonly List<(int index, float dps)> _availableGroups = new List<(int index, float dps)>(); private readonly List _availableLockingWeapons = new List(); @@ -78,6 +84,8 @@ public class CombatState : BaseState foreach(var w in _availableLockingWeapons) w.Activate(); + UseConsumables(toTarget, targetDistance); + var selectedGroup = -1; var maxDps = Single.MinValue; foreach (var group in _availableGroups) @@ -132,6 +140,27 @@ public class CombatState : BaseState // Fire charged guns! } + // The AI plays the player's rule on the player's information: vent when one Track paint on this ship passes half, + // otherwise overdrive to close a distant target ahead. It reads only this ship's own IncomingPaints. Both go + // through TryActivateConsumable, and a ship carrying neither does nothing. + private void UseConsumables(float3 toTarget, float targetDistance) + { + var ship = _agent.Ship; + if (ship.IncomingPaints.Any(p => p.Mode == PaintMode.Track && p.Strength > LockAlarm)) + Use(b => b is VapourDumpData); + else if (targetDistance > OverdriveRangeFactor * _optimumRange && + dot(normalize(ship.Direction), normalize(toTarget.xz)) > cos(radians(OverdriveArcDegrees))) + Use(b => b is ThrottleLockData); + } + + // One of a kind at a time: a Stackable design would otherwise be drained once per tick. + private void Use(Predicate kind) + { + if (_agent.Ship.HasActiveConsumable(kind)) return; + var consumable = _agent.Ship.FirstCarriedConsumable(kind); + if (consumable != null) _agent.Ship.TryActivateConsumable(consumable); + } + private void SampleDps() { var minRange = Single.MaxValue; diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 4d9470963..53af5d165 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -1350,8 +1350,20 @@ public abstract class Entity return data.Stackable || FindActiveConsumable(data) == null; } + // The first consumable design in cargo with a behaviour of the asked kind, or null. Callers ask by kind, never + // by name: a design's name is a label and its behaviours are what it does. + public ConsumableItemData FirstCarriedConsumable(Predicate kind) => + CargoBays.SelectMany(bay => bay.Cargo.Keys).OfType() + .Select(item => ItemManager.GetData(item) as ConsumableItemData) + .FirstOrDefault(data => data != null && data.Behaviors.Any(b => kind(b))); + + public bool HasActiveConsumable(Predicate kind) => + _activeConsumables.Any(e => e.Data.Behaviors.Any(b => kind(b))); + public bool TryActivateConsumable(ConsumableItemData data) { + // Death and docking Remove and Deactivate the ship while its agent keeps ticking. + if (!_active) return false; if (!CanActivateConsumable(data)) return false; var key = ItemManager.ItemData.RefOf(data).Key; diff --git a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs new file mode 100644 index 000000000..7dc8b4ae1 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs @@ -0,0 +1,344 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using CultMath; +using GameCult.Caching; +using Xunit; +using static CultMath.math; + +// AI pilots use consumables (aetheria-release cut consumable-ai r3; rulings sensor-stat-set item 4, +// lock-warning-strength, launcher-lock-solution-quality): CombatState vents when ONE Track paint in the ship's own +// IncomingPaints passes .5 and otherwise overdrives to close a target farther than twice its optimum range while +// heading within 15 degrees of it. It reads only its own ship's senses. A consumable is chosen by what its behaviours +// are, never by name, so the vent design is named "Overdrive" and the overdrive design "Coolant". +// +// The agent's one gun has Range 1000 and EffectiveRange 500 with nothing at maximum range, so its optimum range is +// about 500 while its shortest and longest gun range are both 1000: a threshold taken from either gun range is wrong +// and these tests can tell. The optimum is read by reflection, as FireControlTests does, and the thresholds are +// authored against it. +public sealed class ConsumableAiTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-consumable-ai-" + Guid.NewGuid().ToString("N")); + private readonly List _caches = new List(); + private static readonly int2 HardpointCell = new int2(0, 0); + private static readonly int2 HoldCell = new int2(1, 1); + private const BindingFlags Private = BindingFlags.Instance | BindingFlags.NonPublic; + + public ConsumableAiTests() => Directory.CreateDirectory(_root); + + public void Dispose() + { + foreach (var c in _caches) c.Dispose(); + Directory.Delete(_root, true); + } + + private static PerformanceStat Const(float v) => new PerformanceStat { Min = v, Max = v }; + + private sealed class Scene + { + public ItemManager Items; + public Zone Zone; + public Ship Agent, Prey, Locker; + public EquippedCargoBay Hold; + public ConsumableItemData Vent, Overdrive, Decoy, StackableVent; + public CultRecordRef Maker; + public float Optimum; + + public void Store(ConsumableItemData design) => + Assert.True(Hold.TryStore((ItemInstance) Items.CreateInstance(Items.CreateLot(design, Maker, .5f)))); + + public bool Active(ConsumableItemData design) => Agent.FindActiveConsumable(design) != null; + public int Carried => Hold.Cargo.Keys.Count(i => i is ConsumableItem); + + public object State => typeof(Agent).GetField("_currentState", Private).GetValue(Zone.Agents.Single()); + public void AssertInCombat() => Assert.True(State is CombatState, $"the agent is in {State?.GetType().Name}"); + + // The target at `distance` and `degrees` off the hull's heading, the agent at the origin heading (0, 1). + public void Place(float distance, float degrees) + { + Agent.Position = float3.zero; + Agent.Direction = float2(0, 1); + var a = degrees * 0.0174533f; + Prey.Position = float3(sin(a) * distance, 0, cos(a) * distance); + } + + // Paints of the locker's launcher reaching the agent as the zone publishes them, then one tick in which the + // agent decides. The agent reads them before the zone republishes (agents update, then PublishInbox). + public void Tick(params (PaintMode mode, float strength)[] paints) + { + foreach (var (mode, strength) in paints) + Agent.ReceivePaint(Locker, Locker.Equipment.Single(i => i.Behaviors.Any(b => b is LockWeapon)), mode, strength); + Agent.PublishInbox(); + Zone.Update(.05f); + } + } + + private static GearData GunData() => new GearData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = + { + new InstantWeaponData + { + Damage = Const(10), Range = Const(1000), MinRange = Const(0), Velocity = Const(0), + Spread = Const(0), DamageSpread = Const(0), Penetration = Const(0), Count = Const(1), + BurstTime = Const(0), Cooldown = Const(1), + EffectiveRange = Const(500), DamageAtMaxRange = 0f + } + } + }; + + private Scene Build(bool carryVent = false, bool carryOverdrive = false, bool carryDecoy = false, int stackableVents = 0) + { + var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true); + _caches.Add(cache); + cache.Upsert(new VerseGrammar { Revision = 1 }); + cache.Upsert(new Faction { Name = "Maker", ShortName = "MKR" }); + var hullShape = new Shape(3, 3); + foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; + cache.Upsert(new HullData + { + Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 1000000, Mass = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = HardpointCell, Shape = new Shape() } } + }); + cache.Upsert(GunData()); + cache.Upsert(LockWarningTests.Launcher("Launcher", lockSpeed: .6f, sensorImpact: 1, lockAngle: 30, directionImpact: 1)); + var interior = new Shape(4, 1); + foreach (var cell in interior.AllCoordinates) interior[cell] = true; + cache.Upsert(new CargoBayData { Name = "Hold", Shape = new Shape(), InteriorShape = interior, Durability = 10 }); + // A vent named like the other kind, an overdrive named like it, a decoy, and a stackable vent. + cache.Upsert(new ConsumableItemData + { + Name = "Overdrive", Duration = 100f, Shape = new Shape(), + Behaviors = { new VapourDumpData { Radius = Const(20), Opacity = Const(.9f), Lifetime = Const(100) } } + }); + cache.Upsert(new ConsumableItemData { Name = "Coolant", Duration = 100f, Shape = new Shape(), Behaviors = { new ThrottleLockData() } }); + cache.Upsert(new ConsumableItemData { Name = "Flare", Duration = 1f, Shape = new Shape() }); + cache.Upsert(new ConsumableItemData + { + Name = "StackVent", Duration = 100f, Stackable = true, Shape = new Shape(), + Behaviors = { new VapourDumpData { Radius = Const(20), Opacity = Const(.9f), Lifetime = Const(100) } } + }); + cache.FlushAsync().Wait(); + + var settings = RunSaveTests.TestSettings(); + settings.TargetDetectionInfoThreshold = .1f; + settings.TargetGearInfoThreshold = .8f; + settings.FiringArc = 170; + settings.CommitHorizon = .5f; + settings.SchematicCellSize = 1f; + settings.UnaidedAccuracy = .05f; + settings.UnaidedTracking = 10f; + settings.UnaidedPrecision = 2f; + settings.AgentMinHitProbability = 0f; + settings.BeamResolveInterval = .1f; + var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var maker = cache.RefOf(cache.GetByName("Maker")); + EquippableItem Make(string name) where T : CraftedItemData => + (EquippableItem) items.CreateInstance(items.CreateLot(cache.GetByName(name), maker, .5f)); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + Ship NewShip(string gear) + { + var ship = new Ship(items, zone, Make("Skiff"), new EntitySettings()) + { Faction = new Faction { Name = "F" + Guid.NewGuid().ToString("N") } }; + if (gear != null) Assert.True(ship.TryEquip(Make(gear), HardpointCell)); + return ship; + } + + var scene = new Scene + { + Items = items, Zone = zone, Maker = maker, + Vent = cache.GetByName("Overdrive"), + Overdrive = cache.GetByName("Coolant"), + Decoy = cache.GetByName("Flare"), + StackableVent = cache.GetByName("StackVent"), + Agent = NewShip("Gun"), Prey = NewShip(null), Locker = NewShip("Launcher") + }; + Assert.True(scene.Agent.TryEquip(Make("Hold"), HoldCell)); + scene.Hold = scene.Agent.CargoBays.Single(); + if (carryVent) scene.Store(scene.Vent); + if (carryOverdrive) scene.Store(scene.Overdrive); + if (carryDecoy) scene.Store(scene.Decoy); + for (var i = 0; i < stackableVents; i++) scene.Store(scene.StackableVent); + + // The agent is driven by a Minion added by hand; the locker is a gun ship nobody pilots. + zone.Admit(scene.Agent, piloted: false); + zone.Admit(scene.Prey, piloted: false); + zone.Admit(scene.Locker, piloted: false); + zone.Agents.Add(new Minion(scene.Agent)); + zone.Update(0f); // brings the gear online + scene.Agent.SetIff(scene.Prey, true); + scene.Agent.EntityInfoGathered[scene.Prey] = 1f; + scene.Agent.SetTarget(scene.Prey); + scene.Locker.Position = float3(0, 0, -40); + scene.Place(100f, 0f); + zone.Update(.05f); + zone.Update(.05f); + scene.AssertInCombat(); + + // The state sampled before the weapons' first stat evaluation: sample again over the live stats. + typeof(CombatState).GetMethod("SampleDps", Private).Invoke(scene.State, null); + scene.Optimum = (float) typeof(CombatState).GetField("_optimumRange", Private).GetValue(scene.State); + Assert.InRange(scene.Optimum, 500f - 1000f / 32f, 500f + 1000f / 32f); + return scene; + } + + [Theory] + [InlineData(.4f, false)] + [InlineData(.49f, false)] + [InlineData(.5f, false)] + [InlineData(.51f, true)] + [InlineData(.6f, true)] + public void AnAgentVentsWhenLocked(float strength, bool vents) + { + var s = Build(carryVent: true, carryOverdrive: true); + s.AssertInCombat(); + + s.Tick((PaintMode.Track, strength)); + + Assert.Equal(vents, s.Active(s.Vent)); + Assert.Equal(vents ? 1 : 2, s.Carried); + Assert.False(s.Active(s.Overdrive)); + } + + [Fact] + public void AnAgentVentsOnOneTrackPaintOnItself() + { + var s = Build(carryVent: true); + s.AssertInCombat(); + + s.Tick((PaintMode.Track, .3f), (PaintMode.Track, .3f)); // two weak locks never sum + s.Tick((PaintMode.Search, .9f)); + s.Tick((PaintMode.Scan, .9f)); + Assert.False(s.Active(s.Vent)); + Assert.Equal(1, s.Carried); + + // The locker is a ship the agent has no information on, and one strong Track paint on it is enough. + s.Tick((PaintMode.Track, .9f)); + Assert.True(s.Active(s.Vent)); + Assert.Equal(0, s.Carried); + } + + // The production path: a real launcher on a ship the agent has never sensed locks it through zone ticks, and the + // agent vents once the paint is published, with its own senses as the only input. + [Fact] + public void AnUnseenLockerThatLocksTheAgentMakesItVent() + { + var s = Build(carryVent: true); + s.AssertInCombat(); + s.Locker.SetIff(s.Agent, true); + s.Locker.EntityInfoGathered[s.Agent] = 1f; + s.Locker.SetTarget(s.Agent); + + for (var i = 0; i < 4 && !s.Active(s.Vent); i++) + { + s.Place(100f, 0f); + s.Locker.Aim = normalize(s.Agent.Position - s.Locker.Position); + s.Zone.Update(1f); + } + + Assert.True(s.Active(s.Vent)); + Assert.Equal(0, s.Carried); + Assert.Equal(s.Prey, s.Agent.Target.Value.Entity); + } + + [Fact] + public void AnAgentThatIsLockedVentsInsteadOfOverdriving() + { + var s = Build(carryVent: true, carryOverdrive: true); + s.AssertInCombat(); + s.Place(3 * s.Optimum, 0f); + + s.Tick((PaintMode.Track, .6f)); + + Assert.True(s.Active(s.Vent)); + Assert.False(s.Active(s.Overdrive)); + Assert.Equal(1, s.Carried); + } + + [Theory] + [InlineData(0f, 2f, 20f, true)] + [InlineData(14f, 2f, 20f, true)] + [InlineData(16f, 2f, 20f, false)] + [InlineData(30f, 2f, 20f, false)] + [InlineData(180f, 2f, 20f, false)] + [InlineData(0f, 2f, -20f, false)] + [InlineData(0f, 1f, 0f, false)] + public void AnAgentOverdrivesToClose(float degrees, float optimums, float offset, bool overdrives) + { + var s = Build(carryVent: true, carryOverdrive: true); + s.AssertInCombat(); + s.Place(optimums * s.Optimum + offset, degrees); + + s.Tick(); + + Assert.Equal(overdrives, s.Active(s.Overdrive)); + Assert.False(s.Active(s.Vent)); + Assert.Equal(overdrives ? 1 : 2, s.Carried); + } + + [Fact] + public void AnOverdriveIsNotStoppedByAWeakTrackPaint() + { + var s = Build(carryVent: true, carryOverdrive: true); + s.AssertInCombat(); + s.Place(2 * s.Optimum + 20f, 0f); + + s.Tick((PaintMode.Track, .4f)); + + Assert.True(s.Active(s.Overdrive)); + Assert.False(s.Active(s.Vent)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AnAgentWithoutConsumablesDoesNothing(bool carryDecoy) + { + var s = Build(carryDecoy: carryDecoy); + s.AssertInCombat(); + + s.Tick((PaintMode.Track, .9f)); + s.Place(3 * s.Optimum, 0f); + s.Tick(); + + Assert.False(s.Active(s.Decoy)); + Assert.Equal(carryDecoy ? 1 : 0, s.Carried); + } + + [Fact] + public void ADeadOrDockedShipActivatesNothing() + { + var s = Build(carryVent: true, carryOverdrive: true); + s.AssertInCombat(); + Assert.True(s.Zone.Remove(s.Agent)); // what death and docking do + s.Agent.Deactivate(); + s.Place(3 * s.Optimum, 0f); + + s.Tick(); + s.Tick(); + + Assert.Equal(2, s.Carried); + Assert.False(s.Active(s.Vent)); + Assert.False(s.Active(s.Overdrive)); + Assert.False(s.Agent.TryActivateConsumable(s.Overdrive)); + Assert.Equal(2, s.Carried); + } + + [Fact] + public void AStackOfVentsIsSpentOneAtATime() + { + var s = Build(stackableVents: 3); + s.AssertInCombat(); + + s.Tick((PaintMode.Track, .6f)); + s.Tick((PaintMode.Track, .6f)); + s.Tick((PaintMode.Track, .6f)); + + Assert.True(s.Active(s.StackableVent)); + Assert.Equal(2, s.Carried); + } +} diff --git a/tests/Aetheria.Shared.Tests/LockWarningTests.cs b/tests/Aetheria.Shared.Tests/LockWarningTests.cs index 582a1b78f..18b068e63 100644 --- a/tests/Aetheria.Shared.Tests/LockWarningTests.cs +++ b/tests/Aetheria.Shared.Tests/LockWarningTests.cs @@ -67,7 +67,7 @@ public sealed class LockWarningTests : IDisposable private static PerformanceStat Const(float v) => new PerformanceStat { Min = v, Max = v }; - private static GearData Launcher(string name, float lockSpeed, float sensorImpact, float lockAngle, float directionImpact) => WithMunitionParts(new GearData + internal static GearData Launcher(string name, float lockSpeed, float sensorImpact, float lockAngle, float directionImpact) => WithMunitionParts(new GearData { Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, @@ -159,6 +159,7 @@ public sealed class LockWarningTests : IDisposable [InlineData(1.55f, .93f)] [InlineData(1.6f, .96f)] [InlineData(1.65f, .99f)] + [InlineData(1.66f, .996f)] public void StrengthIsTheLockAcrossItsWholeRange(float dt, float expectedLock) { var locker = NewShip(float3(0, 0, 0), "Launcher"); From f39ce7850ed8a815d2e2a284a9cbc120e9ad2bc8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 17:26:14 +0200 Subject: [PATCH 03/12] ConsumableAiTests: alias the CultMath vector types the static math import shadows --- tests/Aetheria.Shared.Tests/ConsumableAiTests.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs index 7dc8b4ae1..c19375b19 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs @@ -7,6 +7,9 @@ using CultMath; using GameCult.Caching; using Xunit; using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; // AI pilots use consumables (aetheria-release cut consumable-ai r3; rulings sensor-stat-set item 4, // lock-warning-strength, launcher-lock-solution-quality): CombatState vents when ONE Track paint in the ship's own From 9b1ac4802784bc350ba0754d76ae5ba6a669ad53 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 17:29:20 +0200 Subject: [PATCH 04/12] ConsumableAiTests: assert the fixture carries what it stored --- tests/Aetheria.Shared.Tests/ConsumableAiTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs index c19375b19..9117e7d91 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs @@ -166,6 +166,8 @@ public sealed class ConsumableAiTests : IDisposable if (carryOverdrive) scene.Store(scene.Overdrive); if (carryDecoy) scene.Store(scene.Decoy); for (var i = 0; i < stackableVents; i++) scene.Store(scene.StackableVent); + Assert.True(scene.Carried == (carryVent ? 1 : 0) + (carryOverdrive ? 1 : 0) + (carryDecoy ? 1 : 0) + stackableVents, + $"carried {scene.Carried}, cargo entries {scene.Hold.Cargo.Count}: {string.Join(",", scene.Hold.Cargo.Keys.Select(k => k.GetType().Name))}"); // The agent is driven by a Minion added by hand; the locker is a gun ship nobody pilots. zone.Admit(scene.Agent, piloted: false); From 2f1f392ecfc9202f36cfee393ec780e5641a0aa3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 17:30:32 +0200 Subject: [PATCH 05/12] ConsumableAiTests: stock the hold after the optimum is sampled --- tests/Aetheria.Shared.Tests/ConsumableAiTests.cs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs index 9117e7d91..87b009244 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs @@ -162,13 +162,6 @@ public sealed class ConsumableAiTests : IDisposable }; Assert.True(scene.Agent.TryEquip(Make("Hold"), HoldCell)); scene.Hold = scene.Agent.CargoBays.Single(); - if (carryVent) scene.Store(scene.Vent); - if (carryOverdrive) scene.Store(scene.Overdrive); - if (carryDecoy) scene.Store(scene.Decoy); - for (var i = 0; i < stackableVents; i++) scene.Store(scene.StackableVent); - Assert.True(scene.Carried == (carryVent ? 1 : 0) + (carryOverdrive ? 1 : 0) + (carryDecoy ? 1 : 0) + stackableVents, - $"carried {scene.Carried}, cargo entries {scene.Hold.Cargo.Count}: {string.Join(",", scene.Hold.Cargo.Keys.Select(k => k.GetType().Name))}"); - // The agent is driven by a Minion added by hand; the locker is a gun ship nobody pilots. zone.Admit(scene.Agent, piloted: false); zone.Admit(scene.Prey, piloted: false); @@ -188,6 +181,14 @@ public sealed class ConsumableAiTests : IDisposable typeof(CombatState).GetMethod("SampleDps", Private).Invoke(scene.State, null); scene.Optimum = (float) typeof(CombatState).GetField("_optimumRange", Private).GetValue(scene.State); Assert.InRange(scene.Optimum, 500f - 1000f / 32f, 500f + 1000f / 32f); + + // Stocked only now: before the first stat evaluation the optimum reads 0 and any target looks distant. + if (carryVent) scene.Store(scene.Vent); + if (carryOverdrive) scene.Store(scene.Overdrive); + if (carryDecoy) scene.Store(scene.Decoy); + for (var i = 0; i < stackableVents; i++) scene.Store(scene.StackableVent); + Assert.True(scene.Carried == (carryVent ? 1 : 0) + (carryOverdrive ? 1 : 0) + (carryDecoy ? 1 : 0) + stackableVents, + $"carried {scene.Carried}, cargo entries {scene.Hold.Cargo.Count}: {string.Join(",", scene.Hold.Cargo.Keys.Select(k => k.GetType().Name))}"); return scene; } From 5b1bfe6cec9c7045614fad028f336c6e7bf34e56 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 17:38:37 +0200 Subject: [PATCH 06/12] ColorMath.RgbToHcv: read green-dominant colours with the right channel order The else branch swapped y and z, so the hue read back from many colours was wrong (values above 1). Nothing else consumed it; the sun-hue test reads hues back from RGB. Builds; first run of SunsNeverGreenTests failed on this. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ColorMath.cs | 2 +- tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/ColorMath.cs b/Assets/Scripts/ServerShared/ColorMath.cs index 26ea704a7..f91ab9ad0 100644 --- a/Assets/Scripts/ServerShared/ColorMath.cs +++ b/Assets/Scripts/ServerShared/ColorMath.cs @@ -41,7 +41,7 @@ public static class ColorMath public static float3 RgbToHcv(in float3 rgb) { // Based on work by Sam Hocevar and Emil Persson - float4 p = (rgb.y < rgb.z) ? float4(rgb.zy, -1.0f, 2.0f/3.0f) : float4(rgb.zy, 0.0f, -1.0f/3.0f); + float4 p = (rgb.y < rgb.z) ? float4(rgb.zy, -1.0f, 2.0f/3.0f) : float4(rgb.yz, 0.0f, -1.0f/3.0f); float4 q = (rgb.x < p.x) ? float4(p.xyw, rgb.x) : float4(rgb.x, p.yzx); float c = q.x - min(q.w, q.y); float h = abs((q.w - q.y) / (6 * c + Epsilon) + q.z); diff --git a/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs b/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs index e94deab92..fc42536cc 100644 --- a/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs +++ b/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs @@ -53,6 +53,18 @@ public sealed class SunsNeverGreenTests : IDisposable Assert.False(ColorMath.HueInBand(degrees * Degree, settings.SunHueExcludedStart, settings.SunHueExcludedEnd)); } + // The hues below are read back from RGB the generator wrote, so the reader must invert the writer. + [Fact] + public void ReadingHueBackFromRgbInvertsWritingIt() + { + for (var degrees = 5; degrees < 360; degrees += 5) + foreach (var value in new[] { .5f, 1f }) + { + var rgb = ColorMath.HsvToRgb(new float3(degrees * Degree, .75f, value)); + Assert.Equal(degrees * Degree, ColorMath.RgbToHsv(rgb).x, 4); + } + } + [Fact] public void TheDrawMapsOntoTheWholeAllowedArcAndNothingElse() { From bf32a82e3239ab2baff658bc613b406cfb676365 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 19:03:50 +0200 Subject: [PATCH 07/12] CombatState derives its optimum range once the weapons' stats are known, and re-derives on loadout change A zone-spawned ship's CombatState sampled in its constructor, before any weapon had evaluated its stats, so the optimum range read 0 and never moved. It now marks the optimum stale on construction, on any equipment add/remove and on a weapon's online change, and samples at the next Update once every active weapon reports StatsKnown. ConsumableAiTests build the agent through Zone.Admit(piloted: true) and read the optimum without re-sampling; new rows pin the hold scope, hull-heading source, mixed paints, sight independence and the optimum across two guns and a refit. Not yet verified. Co-Authored-By: Claude Opus 5.5 --- .../ServerShared/Agents/States/Combat.cs | 23 ++- .../Scripts/ServerShared/Behaviors/Weapon.cs | 4 + .../ConsumableAiTests.cs | 168 +++++++++++++++--- 3 files changed, 166 insertions(+), 29 deletions(-) diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index d7c20f275..3e10a6d2a 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -17,20 +17,33 @@ public class CombatState : BaseState private const float OverdriveRangeFactor = 2f; private const float OverdriveArcDegrees = 15f; private float _optimumRange; + private bool _optimumStale = true; private readonly List<(int index, float dps)> _availableGroups = new List<(int index, float dps)>(); private readonly List _availableLockingWeapons = new List(); public CombatState(Agent agent) : base(agent) { - SampleDps(); - foreach (var weapon in _agent.Ship.Weapons) - { - weapon.Item.Online.Subscribe(b => SampleDps()); - } + foreach (var item in _agent.Ship.Equipment) Watch(item); + _agent.Ship.Equipment.ObserveAdd().Subscribe(add => { Watch(add.Value); _optimumStale = true; }); + _agent.Ship.Equipment.ObserveRemove().Subscribe(_ => _optimumStale = true); + } + + // The optimum is a function of the loadout's evaluated stats, so it is re-derived whenever a weapon comes or goes + // or changes online state, and only once every weapon that can run has evaluated its stats (a weapon evaluates on + // its first active tick, after it comes online): a ship the zone just spawned samples over live stats, not zeros. + private void Watch(EquippedItem item) + { + if (item.Behaviors.OfType().Any()) item.Online.Subscribe(_ => _optimumStale = true); } public override void Update(float delta) { + if (_optimumStale && _agent.Ship.Weapons.All(w => w.StatsKnown || !w.Item.Active.Value)) + { + SampleDps(); + _optimumStale = false; + } + var target = _agent.Ship.Target.Value.Entity; if (target == null) return; diff --git a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs index 6a01f81ca..05edbb887 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs @@ -173,9 +173,13 @@ public abstract class Weapon : Behavior, IActivatedBehavior Tracking = Evaluate(_data.Tracking); } + // False until the first active tick has evaluated the stats above: before it they all read zero. + public bool StatsKnown { get; private set; } + public override bool Execute(float dt) { UpdateStats(); + StatsKnown = true; return true; } diff --git a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs index 87b009244..57418fea0 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs @@ -46,6 +46,7 @@ public sealed class ConsumableAiTests : IDisposable public Ship Agent, Prey, Locker; public EquippedCargoBay Hold; public ConsumableItemData Vent, Overdrive, Decoy, StackableVent; + public GearData LongGun; public CultRecordRef Maker; public float Optimum; @@ -58,11 +59,12 @@ public sealed class ConsumableAiTests : IDisposable public object State => typeof(Agent).GetField("_currentState", Private).GetValue(Zone.Agents.Single()); public void AssertInCombat() => Assert.True(State is CombatState, $"the agent is in {State?.GetType().Name}"); - // The target at `distance` and `degrees` off the hull's heading, the agent at the origin heading (0, 1). - public void Place(float distance, float degrees) + // The target at `distance` and `degrees` off north, the agent at the origin with its hull `hullDegrees` off north. + public void Place(float distance, float degrees, float hullDegrees = 0f) { Agent.Position = float3.zero; - Agent.Direction = float2(0, 1); + var h = hullDegrees * 0.0174533f; + Agent.Direction = float2(sin(h), cos(h)); var a = degrees * 0.0174533f; Prey.Position = float3(sin(a) * distance, 0, cos(a) * distance); } @@ -78,9 +80,9 @@ public sealed class ConsumableAiTests : IDisposable } } - private static GearData GunData() => new GearData + private static GearData GunData(float effective, string name = "Gun") => new GearData { - Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, Behaviors = { @@ -89,12 +91,12 @@ public sealed class ConsumableAiTests : IDisposable Damage = Const(10), Range = Const(1000), MinRange = Const(0), Velocity = Const(0), Spread = Const(0), DamageSpread = Const(0), Penetration = Const(0), Count = Const(1), BurstTime = Const(0), Cooldown = Const(1), - EffectiveRange = Const(500), DamageAtMaxRange = 0f + EffectiveRange = Const(effective), DamageAtMaxRange = 0f } } }; - private Scene Build(bool carryVent = false, bool carryOverdrive = false, bool carryDecoy = false, int stackableVents = 0) + private Scene Build(bool carryVent = false, bool carryOverdrive = false, bool carryDecoy = false, int stackableVents = 0, float effective = 500f) { var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true); _caches.Add(cache); @@ -107,7 +109,8 @@ public sealed class ConsumableAiTests : IDisposable Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 1000000, Mass = 1000, Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = HardpointCell, Shape = new Shape() } } }); - cache.Upsert(GunData()); + cache.Upsert(GunData(effective)); + cache.Upsert(GunData(800f, "LongGun")); cache.Upsert(LockWarningTests.Launcher("Launcher", lockSpeed: .6f, sensorImpact: 1, lockAngle: 30, directionImpact: 1)); var interior = new Shape(4, 1); foreach (var cell in interior.AllCoordinates) interior[cell] = true; @@ -158,15 +161,16 @@ public sealed class ConsumableAiTests : IDisposable Overdrive = cache.GetByName("Coolant"), Decoy = cache.GetByName("Flare"), StackableVent = cache.GetByName("StackVent"), + LongGun = cache.GetByName("LongGun"), Agent = NewShip("Gun"), Prey = NewShip(null), Locker = NewShip("Launcher") }; Assert.True(scene.Agent.TryEquip(Make("Hold"), HoldCell)); scene.Hold = scene.Agent.CargoBays.Single(); - // The agent is driven by a Minion added by hand; the locker is a gun ship nobody pilots. - zone.Admit(scene.Agent, piloted: false); + // The agent joins as the zone admits a piloted ship (its Minion and CombatState come from production); the + // locker is a gun ship nobody pilots. + zone.Admit(scene.Agent, piloted: true); zone.Admit(scene.Prey, piloted: false); zone.Admit(scene.Locker, piloted: false); - zone.Agents.Add(new Minion(scene.Agent)); zone.Update(0f); // brings the gear online scene.Agent.SetIff(scene.Prey, true); scene.Agent.EntityInfoGathered[scene.Prey] = 1f; @@ -177,12 +181,10 @@ public sealed class ConsumableAiTests : IDisposable zone.Update(.05f); scene.AssertInCombat(); - // The state sampled before the weapons' first stat evaluation: sample again over the live stats. - typeof(CombatState).GetMethod("SampleDps", Private).Invoke(scene.State, null); + // Read, never re-sampled: the optimum is whatever production derived for the spawned ship. scene.Optimum = (float) typeof(CombatState).GetField("_optimumRange", Private).GetValue(scene.State); - Assert.InRange(scene.Optimum, 500f - 1000f / 32f, 500f + 1000f / 32f); + Assert.InRange(scene.Optimum, effective - 1000f / 32f, effective + 1000f / 32f); - // Stocked only now: before the first stat evaluation the optimum reads 0 and any target looks distant. if (carryVent) scene.Store(scene.Vent); if (carryOverdrive) scene.Store(scene.Overdrive); if (carryDecoy) scene.Store(scene.Decoy); @@ -265,17 +267,24 @@ public sealed class ConsumableAiTests : IDisposable Assert.Equal(1, s.Carried); } + // Two guns, optimums about 300 and 700: the threshold is twice the optimum the ship derived, so no constant, no gun + // range and no scaling of the arc by distance can sit at 2 x optimum +- 20 for both. [Theory] - [InlineData(0f, 2f, 20f, true)] - [InlineData(14f, 2f, 20f, true)] - [InlineData(16f, 2f, 20f, false)] - [InlineData(30f, 2f, 20f, false)] - [InlineData(180f, 2f, 20f, false)] - [InlineData(0f, 2f, -20f, false)] - [InlineData(0f, 1f, 0f, false)] - public void AnAgentOverdrivesToClose(float degrees, float optimums, float offset, bool overdrives) + [InlineData(300f, 0f, 2f, 20f, true)] + [InlineData(300f, 14f, 2f, 20f, true)] + [InlineData(300f, 16f, 2f, 20f, false)] + [InlineData(300f, 30f, 2f, 20f, false)] + [InlineData(300f, 180f, 2f, 20f, false)] + [InlineData(300f, 0f, 2f, -20f, false)] + [InlineData(300f, 0f, 1f, 0f, false)] + [InlineData(700f, 0f, 2f, 20f, true)] + [InlineData(700f, 14f, 2f, 20f, true)] + [InlineData(700f, 16f, 2f, 20f, false)] + [InlineData(700f, 0f, 2f, -20f, false)] + [InlineData(700f, 0f, 1f, 0f, false)] + public void AnAgentOverdrivesToClose(float effective, float degrees, float optimums, float offset, bool overdrives) { - var s = Build(carryVent: true, carryOverdrive: true); + var s = Build(carryVent: true, carryOverdrive: true, effective: effective); s.AssertInCombat(); s.Place(optimums * s.Optimum + offset, degrees); @@ -286,6 +295,80 @@ public sealed class ConsumableAiTests : IDisposable Assert.Equal(overdrives ? 1 : 2, s.Carried); } + // The arc is the hull's heading against the bearing to the target; the aim always points at the target, so an + // arc taken from the aim would surge with the hull pointing away. + [Theory] + [InlineData(0f, 14f, true)] + [InlineData(0f, -14f, true)] + [InlineData(0f, 16f, false)] + [InlineData(0f, 90f, false)] + public void TheArcIsTheHullsHeadingNotTheAim(float targetDegrees, float hullDegrees, bool overdrives) + { + var s = Build(carryOverdrive: true); + s.AssertInCombat(); + s.Place(3 * s.Optimum, targetDegrees, hullDegrees); + + s.Tick(); + + Assert.Equal(overdrives, s.Active(s.Overdrive)); + } + + // The production path for the optimum: a ship the zone admits as piloted derives it from its gun's live stats + // before it needs it, and re-derives it when its loadout changes. The gun's effective range is 500 and the + // refitted gun's is 800, so the optimum moves with the weapons it has now. + [Fact] + public void ASpawnedShipsOptimumFollowsItsWeaponsAndNeverReadsZero() + { + var s = Build(carryOverdrive: true); + s.AssertInCombat(); + Assert.InRange(s.Optimum, 500f - 1000f / 32f, 500f + 1000f / 32f); + s.Place(30f, 0f); + + s.Tick(); + s.Tick(); + + Assert.False(s.Active(s.Overdrive)); // 30 m is not "distant" against a derived optimum + Assert.Equal(1, s.Carried); + + var longGun = (EquippableItem) s.Items.CreateInstance(s.Items.CreateLot(s.LongGun, s.Maker, .5f)); + Assert.NotNull(s.Agent.TryUnequip(s.Agent.Equipment.Single(e => e.Behaviors.OfType().Any()))); + Assert.True(s.Agent.TryEquip(longGun, HardpointCell)); + s.Zone.Update(.05f); + s.Zone.Update(.05f); + var refit = (float) typeof(CombatState).GetField("_optimumRange", Private).GetValue(s.State); + Assert.NotEqual(s.Optimum, refit); + Assert.InRange(refit, 800f - 1000f / 32f, 800f + 1000f / 32f); + } + + // A Search or Scan paint and a Track paint in one tick, in either order: the strongest Track paint decides. + [Theory] + [InlineData(PaintMode.Search, PaintMode.Track)] + [InlineData(PaintMode.Track, PaintMode.Search)] + [InlineData(PaintMode.Scan, PaintMode.Track)] + [InlineData(PaintMode.Track, PaintMode.Scan)] + public void AMixOfPaintsInOneTickVentsOnTheTrackPaint(PaintMode first, PaintMode second) + { + var s = Build(carryVent: true); + s.AssertInCombat(); + + s.Tick((first, first == PaintMode.Track ? .9f : .2f), (second, second == PaintMode.Track ? .9f : .2f)); + + Assert.True(s.Active(s.Vent)); + Assert.Equal(0, s.Carried); + } + + [Fact] + public void AMixOfWeakTrackAndStrongSearchPaintsDoesNotVent() + { + var s = Build(carryVent: true); + s.AssertInCombat(); + + s.Tick((PaintMode.Search, .9f), (PaintMode.Track, .3f)); + s.Tick((PaintMode.Track, .3f), (PaintMode.Scan, .9f)); + + Assert.False(s.Active(s.Vent)); + } + [Fact] public void AnOverdriveIsNotStoppedByAWeakTrackPaint() { @@ -347,4 +430,41 @@ public sealed class ConsumableAiTests : IDisposable Assert.True(s.Active(s.StackableVent)); Assert.Equal(2, s.Carried); } + + // The hold is per kind: a second vent design in cargo is not spent while one vent is active, and an active + // overdrive does not stop a vent. + [Fact] + public void TheHoldIsPerKindNotPerDesignAndNotPerAnyConsumable() + { + var s = Build(carryVent: true, carryOverdrive: true, stackableVents: 1); + s.AssertInCombat(); + s.Place(3 * s.Optimum, 0f); + s.Tick(); // overdrive + Assert.True(s.Active(s.Overdrive)); + + s.Tick((PaintMode.Track, .6f)); + s.Tick((PaintMode.Track, .6f)); + s.Tick((PaintMode.Track, .6f)); + + Assert.True(s.Active(s.Overdrive)); + Assert.Equal(1, new[] { s.Vent, s.StackableVent }.Count(s.Active)); // one vent, whichever design came first + Assert.Equal(1, s.Carried); // the other vent design is still in cargo + } + + // The vent reads only the agent's own paints: with its target gone from VisibleEnemies (still targeted, no + // longer sensed) and the locker unseen, it still vents. + [Fact] + public void AVentDoesNotNeedAnyEnemyInSight() + { + var s = Build(carryVent: true); + s.AssertInCombat(); + s.Agent.VisibleEnemies.Clear(); + Assert.Equal(s.Prey, s.Agent.Target.Value.Entity); + Assert.DoesNotContain(s.Locker, s.Agent.VisibleEnemies); + + s.Tick((PaintMode.Track, .9f)); + + Assert.Empty(s.Agent.VisibleEnemies); + Assert.True(s.Active(s.Vent)); + } } From 99333cf1a2f405c26ef9eda49e2cf1c7d6e26a93 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 19:04:58 +0200 Subject: [PATCH 08/12] ConsumableAiTests: the admitted agent has no patrol task (the test zone has no orbits) Not yet verified. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ConsumableAiTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs index 57418fea0..9c972a26e 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs @@ -169,6 +169,7 @@ public sealed class ConsumableAiTests : IDisposable // The agent joins as the zone admits a piloted ship (its Minion and CombatState come from production); the // locker is a gun ship nobody pilots. zone.Admit(scene.Agent, piloted: true); + zone.Agents.Single().Task = null; // the test zone has no orbits to patrol; the Minion and its CombatState are production's zone.Admit(scene.Prey, piloted: false); zone.Admit(scene.Locker, piloted: false); zone.Update(0f); // brings the gear online From 7e92c7da93c04844b274ed3c9a30599ee799f4a9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 19:08:59 +0200 Subject: [PATCH 09/12] ConsumableAiTests: refit the gun the way a docked ship does Not yet verified. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ConsumableAiTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs index 9c972a26e..adc8903bb 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs @@ -332,8 +332,12 @@ public sealed class ConsumableAiTests : IDisposable Assert.Equal(1, s.Carried); var longGun = (EquippableItem) s.Items.CreateInstance(s.Items.CreateLot(s.LongGun, s.Maker, .5f)); + Assert.True(s.Zone.Remove(s.Agent)); // refitting is a docked ship's act: out of the zone and inactive + s.Agent.Deactivate(); Assert.NotNull(s.Agent.TryUnequip(s.Agent.Equipment.Single(e => e.Behaviors.OfType().Any()))); Assert.True(s.Agent.TryEquip(longGun, HardpointCell)); + s.Zone.Admit(s.Agent, piloted: false); // back in, the agent's Minion keeps its CombatState + s.Place(30f, 0f); s.Zone.Update(.05f); s.Zone.Update(.05f); var refit = (float) typeof(CombatState).GetField("_optimumRange", Private).GetValue(s.State); From 4930ecd06a772a275223d342e8ab59b636dc3ca5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 19:14:15 +0200 Subject: [PATCH 10/12] Suns: secondary hue stays on the allowed arc with the primary; ramp never crosses the band The secondary is placed along the allowed arc (reflected at its ends, distance capped at half the arc) so the RGB blend between the two stops stays outside the band for any Settings band. Tests sample the production ramp and pin draw uniformity. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ColorMath.cs | 19 +++++++- Assets/Scripts/ServerShared/ZoneGenerator.cs | 9 ++-- .../SunsNeverGreenTests.cs | 46 +++++++++++++++++++ 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/Assets/Scripts/ServerShared/ColorMath.cs b/Assets/Scripts/ServerShared/ColorMath.cs index f91ab9ad0..3814272cd 100644 --- a/Assets/Scripts/ServerShared/ColorMath.cs +++ b/Assets/Scripts/ServerShared/ColorMath.cs @@ -22,7 +22,24 @@ public static class ColorMath public static bool HueInBand(float hue, float start, float end) => frac(hue - start) < frac(end - start); // Maps u in [0,1) onto the hues outside the band, so a uniform draw stays uniform over the allowed arc. - public static float HueOutsideBand(float u, float start, float end) => frac(end + u * (1 - frac(end - start))); + public static float HueOutsideBand(float u, float start, float end) => frac(end + u * AllowedArc(start, end)); + + static float AllowedArc(float start, float end) => 1 - frac(end - start); + + // The second hue of a pair whose first is HueOutsideBand(u): distance away along the allowed arc, on the side given by up, + // reflected to the other side if that would leave the arc. Both hues lie on one arc, so the straight RGB blend between + // the two stops never crosses the band. The distance is capped at half the arc, so the reflection always fits and a + // wide band shortens the distance instead of leaving the secondary inside it. + public static float SecondaryHueOutsideBand(float u, bool up, float distance, float start, float end) + { + float arc = AllowedArc(start, end); + float d = min(distance, arc / 2); + float t = u * arc; + float s = up ? t + d : t - d; + if (s < 0 || s > arc) + s = up ? t - d : t + d; + return frac(end + s); + } public static float3 HsvToRgb(in float3 hsv) { diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index 12a8810e9..c6ec75375 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -159,11 +159,10 @@ public static class ZoneGenerator { if (gas is SunData sun) { - float primary = ColorMath.HueOutsideBand(random.NextFloat(), zoneSettings.SunHueExcludedStart, zoneSettings.SunHueExcludedEnd); - float step = zoneSettings.SunSecondaryColorDistance * (random.NextFloat() > .5 ? 1 : -1); - float secondary = frac(primary + 1 + step); - if (ColorMath.HueInBand(secondary, zoneSettings.SunHueExcludedStart, zoneSettings.SunHueExcludedEnd)) - secondary = frac(primary + 1 - step); + var hueDraw = random.NextFloat(); + float primary = ColorMath.HueOutsideBand(hueDraw, zoneSettings.SunHueExcludedStart, zoneSettings.SunHueExcludedEnd); + float secondary = ColorMath.SecondaryHueOutsideBand(hueDraw, random.NextFloat() > .5, zoneSettings.SunSecondaryColorDistance, + zoneSettings.SunHueExcludedStart, zoneSettings.SunHueExcludedEnd); gas.Colors = new [] { float4(ColorMath.HsvToRgb(float3(primary, zoneSettings.SunColorSaturation, .5f)), 0), diff --git a/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs b/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs index fc42536cc..e6f5314ef 100644 --- a/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs +++ b/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs @@ -113,6 +113,52 @@ public sealed class SunsNeverGreenTests : IDisposable Assert.Contains(hues, hue => ColorMath.HueInBand(hue, 75f * Degree, 165f * Degree)); } + [Fact] + public void TheDrawIsUniformOverTheAllowedArc() + { + var settings = new ZoneGenerationSettings(); + const int draws = 1000, bins = 10; + var counts = new int[bins]; + var arc = 1f - (settings.SunHueExcludedEnd - settings.SunHueExcludedStart); + for (var i = 0; i < draws; i++) + { + var hue = ColorMath.HueOutsideBand((i + .5f) / draws, settings.SunHueExcludedStart, settings.SunHueExcludedEnd); + var along = hue - settings.SunHueExcludedEnd; + if (along < 0) along += 1f; + counts[Math.Min(bins - 1, (int)(along / arc * bins))]++; + } + Assert.All(counts, count => Assert.InRange(count, draws / bins - 1, draws / bins + 1)); + } + + // The body blends its two stops in RGB (Gradient Blend), so the blend is sampled, not just the stops. + [Theory] + [InlineData(75f, 165f)] + [InlineData(55f, 185f)] + [InlineData(20f, 250f)] + public void TheRampBetweenTheStopsNeverCrossesTheBand(float startDegrees, float endDegrees) + { + var suns = GenerateSuns(z => + { + z.SunHueExcludedStart = startDegrees * Degree; + z.SunHueExcludedEnd = endDegrees * Degree; + }, out _); + Assert.True(suns.Count >= 10, $"only {suns.Count} suns generated"); + foreach (var sun in suns) + { + var a = sun.Colors[0].xyz; + var b = sun.Colors[1].xyz; + for (var step = 0; step <= 40; step++) + { + var hue = ColorMath.RgbToHsv(a + (b - a) * (step / 40f)).x; + Assert.False(ColorMath.HueInBand(hue, startDegrees * Degree + Slack, endDegrees * Degree - Slack), + $"the ramp passed through the band (hue {hue / Degree:F1} degrees, step {step}/40)"); + } + foreach (var colourHue in Hues(sun)) + Assert.False(ColorMath.HueInBand(colourHue, startDegrees * Degree + Slack, endDegrees * Degree - Slack), + $"a sun colour fell in the band (hue {colourHue / Degree:F1} degrees)"); + } + } + private static IEnumerable Hues(SunData sun) => sun.Colors.Select(c => c.xyz) .Concat(new[] { sun.FogTintColor, sun.LightColor }) From 1e9a89e401e4b6157647d01b8b0425f4a632726c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 19:28:29 +0200 Subject: [PATCH 11/12] CombatState: drop the StatsKnown gate; a stale optimum is re-derived at the next agent Update Agents update before the entities, so a weapon that came online this tick has evaluated by the next Update; the Weapon.StatsKnown flag and the gate on it were dead machinery and are removed. The add handler no longer marks stale itself (a new item's Online subscription does). The refit test fits and removes a dominating gun. Not yet verified. Co-Authored-By: Claude Opus 5.5 --- .../ServerShared/Agents/States/Combat.cs | 11 ++-- .../Scripts/ServerShared/Behaviors/Weapon.cs | 4 -- .../ConsumableAiTests.cs | 56 ++++++++++++------- 3 files changed, 43 insertions(+), 28 deletions(-) diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index 3e10a6d2a..42f04e3cd 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -24,13 +24,14 @@ public class CombatState : BaseState public CombatState(Agent agent) : base(agent) { foreach (var item in _agent.Ship.Equipment) Watch(item); - _agent.Ship.Equipment.ObserveAdd().Subscribe(add => { Watch(add.Value); _optimumStale = true; }); + _agent.Ship.Equipment.ObserveAdd().Subscribe(add => Watch(add.Value)); _agent.Ship.Equipment.ObserveRemove().Subscribe(_ => _optimumStale = true); } - // The optimum is a function of the loadout's evaluated stats, so it is re-derived whenever a weapon comes or goes - // or changes online state, and only once every weapon that can run has evaluated its stats (a weapon evaluates on - // its first active tick, after it comes online): a ship the zone just spawned samples over live stats, not zeros. + // The optimum is a function of the loadout's evaluated stats, so it is re-derived at the next Update (agents update + // before the entities, so by then the weapons that came online this tick have evaluated) whenever a weapon is + // added, removed or changes online state. Sampling on the event itself would read the stats before they exist: a + // ship the zone just spawned would keep an optimum of 0. private void Watch(EquippedItem item) { if (item.Behaviors.OfType().Any()) item.Online.Subscribe(_ => _optimumStale = true); @@ -38,7 +39,7 @@ public class CombatState : BaseState public override void Update(float delta) { - if (_optimumStale && _agent.Ship.Weapons.All(w => w.StatsKnown || !w.Item.Active.Value)) + if (_optimumStale) { SampleDps(); _optimumStale = false; diff --git a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs index 05edbb887..6a01f81ca 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs @@ -173,13 +173,9 @@ public abstract class Weapon : Behavior, IActivatedBehavior Tracking = Evaluate(_data.Tracking); } - // False until the first active tick has evaluated the stats above: before it they all read zero. - public bool StatsKnown { get; private set; } - public override bool Execute(float dt) { UpdateStats(); - StatsKnown = true; return true; } diff --git a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs index adc8903bb..ca1081585 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs @@ -26,6 +26,7 @@ public sealed class ConsumableAiTests : IDisposable private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-consumable-ai-" + Guid.NewGuid().ToString("N")); private readonly List _caches = new List(); private static readonly int2 HardpointCell = new int2(0, 0); + private static readonly int2 SecondHardpointCell = new int2(2, 2); private static readonly int2 HoldCell = new int2(1, 1); private const BindingFlags Private = BindingFlags.Instance | BindingFlags.NonPublic; @@ -46,7 +47,7 @@ public sealed class ConsumableAiTests : IDisposable public Ship Agent, Prey, Locker; public EquippedCargoBay Hold; public ConsumableItemData Vent, Overdrive, Decoy, StackableVent; - public GearData LongGun; + public GearData HeavyGun; public CultRecordRef Maker; public float Optimum; @@ -80,7 +81,7 @@ public sealed class ConsumableAiTests : IDisposable } } - private static GearData GunData(float effective, string name = "Gun") => new GearData + private static GearData GunData(float effective, string name = "Gun", float damage = 10f) => new GearData { Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, @@ -88,7 +89,7 @@ public sealed class ConsumableAiTests : IDisposable { new InstantWeaponData { - Damage = Const(10), Range = Const(1000), MinRange = Const(0), Velocity = Const(0), + Damage = Const(damage), Range = Const(1000), MinRange = Const(0), Velocity = Const(0), Spread = Const(0), DamageSpread = Const(0), Penetration = Const(0), Count = Const(1), BurstTime = Const(0), Cooldown = Const(1), EffectiveRange = Const(effective), DamageAtMaxRange = 0f @@ -107,10 +108,14 @@ public sealed class ConsumableAiTests : IDisposable cache.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 1000000, Mass = 1000, - Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = HardpointCell, Shape = new Shape() } } + Hardpoints = + { + new HardpointData { Type = HardpointType.Sensors, Position = HardpointCell, Shape = new Shape() }, + new HardpointData { Type = HardpointType.Sensors, Position = SecondHardpointCell, Shape = new Shape() } + } }); cache.Upsert(GunData(effective)); - cache.Upsert(GunData(800f, "LongGun")); + cache.Upsert(GunData(800f, "HeavyGun", damage: 1000f)); cache.Upsert(LockWarningTests.Launcher("Launcher", lockSpeed: .6f, sensorImpact: 1, lockAngle: 30, directionImpact: 1)); var interior = new Shape(4, 1); foreach (var cell in interior.AllCoordinates) interior[cell] = true; @@ -161,7 +166,7 @@ public sealed class ConsumableAiTests : IDisposable Overdrive = cache.GetByName("Coolant"), Decoy = cache.GetByName("Flare"), StackableVent = cache.GetByName("StackVent"), - LongGun = cache.GetByName("LongGun"), + HeavyGun = cache.GetByName("HeavyGun"), Agent = NewShip("Gun"), Prey = NewShip(null), Locker = NewShip("Launcher") }; Assert.True(scene.Agent.TryEquip(Make("Hold"), HoldCell)); @@ -316,7 +321,7 @@ public sealed class ConsumableAiTests : IDisposable // The production path for the optimum: a ship the zone admits as piloted derives it from its gun's live stats // before it needs it, and re-derives it when its loadout changes. The gun's effective range is 500 and the - // refitted gun's is 800, so the optimum moves with the weapons it has now. + // heavy gun's is 800, so the optimum moves with the weapons it has now. [Fact] public void ASpawnedShipsOptimumFollowsItsWeaponsAndNeverReadsZero() { @@ -331,18 +336,31 @@ public sealed class ConsumableAiTests : IDisposable Assert.False(s.Active(s.Overdrive)); // 30 m is not "distant" against a derived optimum Assert.Equal(1, s.Carried); - var longGun = (EquippableItem) s.Items.CreateInstance(s.Items.CreateLot(s.LongGun, s.Maker, .5f)); - Assert.True(s.Zone.Remove(s.Agent)); // refitting is a docked ship's act: out of the zone and inactive - s.Agent.Deactivate(); - Assert.NotNull(s.Agent.TryUnequip(s.Agent.Equipment.Single(e => e.Behaviors.OfType().Any()))); - Assert.True(s.Agent.TryEquip(longGun, HardpointCell)); - s.Zone.Admit(s.Agent, piloted: false); // back in, the agent's Minion keeps its CombatState - s.Place(30f, 0f); - s.Zone.Update(.05f); - s.Zone.Update(.05f); - var refit = (float) typeof(CombatState).GetField("_optimumRange", Private).GetValue(s.State); - Assert.NotEqual(s.Optimum, refit); - Assert.InRange(refit, 800f - 1000f / 32f, 800f + 1000f / 32f); + // A refit is a docked ship's act: out of the zone and inactive, then back in. The heavy gun dominates the + // damage, so with it fitted the optimum sits at its effective range, and without it at the first gun's. + void Refit(Action fit) + { + Assert.True(s.Zone.Remove(s.Agent)); + s.Agent.Deactivate(); + fit(s.Agent); + s.Zone.Admit(s.Agent, piloted: false); // back in, the agent's Minion keeps its CombatState + s.Place(30f, 0f); + s.Zone.Update(.05f); + s.Zone.Update(.05f); + } + float Optimum() => (float) typeof(CombatState).GetField("_optimumRange", Private).GetValue(s.State); + + EquippedItem heavy = null; + Refit(ship => + { + var before = ship.Equipment.ToList(); + Assert.True(ship.TryEquip((EquippableItem) s.Items.CreateInstance(s.Items.CreateLot(s.HeavyGun, s.Maker, .5f)), SecondHardpointCell)); + heavy = ship.Equipment.Single(e => !before.Contains(e)); + }); + Assert.InRange(Optimum(), 800f - 1000f / 32f, 800f + 1000f / 32f); + + Refit(ship => Assert.NotNull(ship.TryUnequip(heavy))); + Assert.InRange(Optimum(), 500f - 1000f / 32f, 500f + 1000f / 32f); } // A Search or Scan paint and a Track paint in one tick, in either order: the strongest Track paint decides. From c1c8d8d7de796088f8b463599058eca7f10f85dc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 20:48:59 +0200 Subject: [PATCH 12/12] Gravity map: B52-B54 multi-root zone node, belt extent, series threshold 0.4; rationale for well-law r3 Co-Authored-By: Claude Opus 5.5 --- docs/aetheria-gravity-map.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/aetheria-gravity-map.md b/docs/aetheria-gravity-map.md index d649ebfd3..a21c2f642 100644 --- a/docs/aetheria-gravity-map.md +++ b/docs/aetheria-gravity-map.md @@ -89,6 +89,9 @@ All read at c6519383 in the detached worktree `C:\aeth-wt\ag2-docs` on 2026-10-1 - **B49. Stardust against the nebula march** (probe 2026-10-16 by imagination-ag4-stardust-order at 6b6e7c7c, Unity 6000.3.24f1 batchmode D3D11, ARPG.unity Main Camera, deferred HDR; every camera MonoBehaviour disabled, an [ImageEffectOpaque, ExecuteAlways] snapshot added that copies its source and _CameraDepthTexture; an Unlit/Color quad 50 u ahead drawn by a CommandBuffer at one event per render; log in the session scratchpad imagination-ag4-stardust-order/probe2.log). The nebula is VolumeCloudRenderer, an [ImageEffectOpaque] OnRenderImage: it marches to _CameraDepthTexture and composites mcol*(1-a)+cloud (CloudShader.shader:161, :361). Seen by the opaque effect (so fogged): BeforeLighting, AfterLighting, BeforeImageEffectsOpaque. Drawn but after it (so on top of the fog): AfterImageEffectsOpaque, BeforeForwardAlpha, which is where 1973b6d2 put the dust. Not run with no forward-opaque object or skybox: BeforeForwardOpaque, AfterForwardOpaque, BeforeSkybox, AfterSkybox. BeforeImageEffectsOpaque ran with every image effect disabled. Only BeforeGBuffer and AfterGBuffer depth writes reach _CameraDepthTexture (a deferred camera copies it from the G-buffer depth), and a single-target colour draw there lands in albedo and is unlit. So the dust has never been in the depth the march reads: before the cut (a queue-2000 renderer drawn forward after lighting) the march ran past it and laid the whole ray's fog over it, as it will again at BeforeImageEffectsOpaque. Dust in front of a cloud is fogged as if behind it; per-particle depth occlusion would need a G-buffer pass writing emission to the lighting target. - **B50. Pockets: drawn extent and nesting, flown against ruled** (`pockets.py`, imagination-ag4-children-reach scratchpad, 2026-10-16, over the B35 bodies.csv, 188 of 1500 zones; it reuses r3's `probe4.py` law and `replace.py` placement, so it is a numpy port, not a Unity render). For each body with a body parent, along the line to the parent (the pocket's tight side): r_v is where its outermost closed contour at the minimap spacing (deepest body / 20, IsolinesUI.shader:178) crosses, r_s the saddle (its basin's edge), both in body radii 3 m^0.25. Flown at 6865af81 (C0: harmonic core, today's generator): planets r_v p50 2.0 (2.5 for planets with moons), 44% with no closed contour of their own; 4% of planets' children inside the drawn extent and 24% inside the basin (gas giants 22%/55%, suns with a body parent 26%/55%). Well-law alone (C1): planets r_v 5.5, no contour 23%; children inside 11%/33% (gas giants 39%/66%). Well-law plus children-within-reach's rules (C4): planets with moons r_v p50 6.5 (p10 2.0, p90 15.1); children inside the drawn extent 51% and inside the basin 91% (gas giants 65%/90%, suns 83%/91%); 32% of planets, mostly moonless, still draw no closed contour. Reading against the operator's flight (follow-up wells-read-as-pockets): "barely larger than themselves" is measured at C0 (about 2 body radii), and most of a planet's moons then sat outside its basin. Both rules move it; what remains (half of moons inside the basin but outside the outermost drawn contour, moonless planets with no contour) is contour spacing, grid-noise-presentation's. Her screenshot (session image 4.png) is the in-world view with orbit rings, not the minimap, so this is a proxy for what she saw. - **B51. Binary members shear, and C4 merges most binaries** (`shear.py`, `stats.py`, same scratchpad and data). At 4fb5cb94 a binary member's period is its partner's own terms at the chord (WellTree.PullAround), so unequal members on one ring get unequal periods: of 1163 generated binaries 99% have unequal periods (ratio p10/50/90 1.016/1.091/1.236); started opposite, they meet after p50 665 s (p10 171 s), 47% within 10 minutes of play. The log-law equilibrium of a pair is two radii, r_i proportional to c_partner (rosette-relative-equilibria.md section 1), which WellTree's one-radius ring group (RingOf, RingWindow) cannot express. Under C4 (replace.py, all 1500 zones) 905 of 1155 non-root binaries cannot keep their members outside each other's Roche limit inside their node's reach and become one body; 9026 massive nodes and 1658 of 3134 belts fold into their parents; non-root binaries keep no circumbinary child (the bound 7.5 D_ring equals the window's top, D_node/6), and root rosettes keep none (7.5 x 0.375 Z exceeds 0.75 Z). +- **B52. Multi-root zones: three shapes for the zone's owner** (imagination-ag4-well-law-r3 scratchpad, 2026-10-16: `ImagWellDump.cs` run on Yggdrasil at 4fb5cb94 (job jE) dumps every orbit of 40 production entrance zones, seeds 1-40, with masses, t = 0 positions and belt extents; `welltree.py` ports WellTree's BuildReaches and EmitTerms in float64; `shapes.py`, `overlap.py`). 32 of the 40 zones have 2-6 massive parentless roots, one per occupied area (ZoneGenerator 93). Metrics: far = |F| r / c(M_zone) at 10 Pack.Radius, r from the origin; foreign = |force at a child's rail point with its own subtree removed, less its parent body's Plummer pull| / that pull, for children of bodies, split between the heaviest root's subzone and the others; depth = deepest drawn potential over the bodies / one body of M_zone at the origin (Soul's metric); plain = share of 60 random points inside 0.75 R where |F| < 1e-3 c(M)/R. *Today* (every root its own global term): far p50 2.21, max 4.16; foreign heavy/light p50 0.49/0.28; depth p50 1.10; plain 0. *Isolated subzones* (one zone node at the roots' barycentre owns the only global term, c(M_zone)[U_global - U(A, 1.5 A)] with A = max over roots of distance to the node + reach; each root's reach max(0.5 x nearest-root distance, 1.5 x support)): far 1.000 (0.997-1.005 from the origin, exact from the barycentre); foreign 0.41/0.17; depth p50 0.82, max 1.42; plain p50 8%, max 28%. *Nested under the heaviest root* (the other roots become its children): far 0.92-1.00 from the origin, exact from the heavy root; foreign 0.41/0.20; depth 0.97; plain 0; every light root then sits fixed on the heavy root's slope. *Superposed inside* (each root reaches past the pack and fades where the zone term rises): inside the pack it is today's field, foreign and depth as today, far exact. Under isolated subzones 73 of 130 roots take the 1.5 B floor rather than half the nearest-root distance, and 210 of 490 ordered root pairs have one root's reach entering the other's support: today's generator gives roots families wider than their areas. A sits at p50 1.43, max 2.70 Pack.Radius. Most of the foreign share in both subzone kinds is siblings and children outside their reach (today's generator), which children-within-reach owns. +- **B53. Belt extent** (same dump, `belts.py`, 155 belts under bodies in 40 zones). With L_F read from the belt orbit's Distance (4fb5cb94), the parent's own pull at a belt's outermost asteroid over its body's Plummer pull is at minimum 0.012 (p01 0.938, 8 belts under 0.99); with L_F read from the outermost asteroid it is 1.000 for every belt. The asteroid spread is the generator's (ZoneGenerator 150: Distance + u (v - 1/2) AsteroidBeltWidth(D)): 42 of 155 belts reach inside their parent's Roche limit and 1 below distance 0. +- **B54. The series threshold, measured** (`threshold.py` on Soul's `band_check.py`: float32 port against 40-digit mpmath quadrature, five (a, b) shapes, 15 radii each). Worst error per unit strength with the series from tau 0.4 (40 terms): 1.0e-6 overall; 3.1e-7 at tau 0.05, 6.1e-7 at 0.3, 4.7e-7 at 0.39 (closed), 9.7e-7 at 0.41, 1.0e-6 at 0.43, 6.5e-7 at 0.6, 4.9e-7 at 1, 2.3e-7 at 2.5, 6.0e-8 at 10. From 0.5: 1.04e-6; 0.6: 1.45e-6; 0.7: 2.63e-6; 1 (4fb5cb94): 1.01e-5 (at tau 1). The closed form is float32-clean only to about tau 0.45 (9.7e-7) and worsens from there (1.5e-6 at 0.6, 1.0e-5 at 1). ## Model page @@ -128,6 +131,10 @@ Cells not filled, plainly: whether a per-body multiplier on c or the core is eve - Why one field quad and not the per-body Gravity Well quads with the new law: B30. The per-body quads also cost a GameObject, a material instance and a scale per body, and a quad per body has to cover every camera's view because log tails have no support radius. - Why a period is derived from `GravityField.Acceleration` rather than written as 2πd/sqrt(c): the formula is the tail law spelled a second time and is wrong inside the core (it would run a child faster than any free orbit there); T = 2π·sqrt(d/a) reads the one law, so the core, and any later change to the law, arrive in the rails with no edit (B35's 234 in-core children get a finite solid-body period). - Why the asteroid periods live on the belt: Pose and BeltTargets must divide by the same float (BeltTargets.cs:62); one array computed at build is one owner, where two calls to the same function are two spellings that only agree by care. +- Why one zone node owns the zone's far field (well-law r3): the conserving property is one global term of the total mass, and a tree with several roots has several. Hierarchical N-body methods put the same fact in structure: Barnes-Hut (1986) and the fast multipole method (Greengard and Rokhlin 1987) keep one root cell holding everything, whose far field is the monopole of the total mass at its centre of mass. So the node sits at the roots' barycentre; it is not an orbit, has no body and no ring group, and gives no rail (roots are stationary, period +inf, so parent-only rails are untouched). A zone with one massive root needs no node: that root keeps its infinite reach and nothing about single-root zones changes. +- Why nesting the light roots under the heaviest is rejected (B52): roots are stationary, so a light sun fixed on the heavy sun's slope sits in a field nothing balances; a ship parked by it drifts toward the heavy sun while the sun's planets keep their rails, which is the coasting-orbit mismatch `tame-tails-parent-only-rails` ruled out. Patched conics (spheres of influence, as KSP flies them) are the prior art for the alternative: each region owned by one body. Isolated subzones are that reading for stationary roots; superposed-inside keeps today's superposition of fixed masses. Which one the operator flies is question `multi-root-zone-shape`. +- Why a belt's extent is the well tree's input and not the placement's alone: "inside L_F a node pulls as its body alone" is the tree's guarantee about every rail round the node, and a belt is a family of rails out to its outermost asteroid (B53). The tree reads that extent; children-within-reach decides where belts go and how wide, and must keep their inner edge outside the Roche margin. +- Why the series threshold is a GravityField constant emitted into the template: B54 sets it at 0.4 by measurement, and a literal `1` in both C# and HLSL is the second spelling `gravity-law-one-spelling` already names. - A constraint for pseudospace, not solved here: a push with curl (B11) has no height, so under `grid-shows-how-you-move` it either never moves ships, or is drawn by another channel, or is restricted to its gradient part. ## Cut sequence @@ -135,7 +142,7 @@ Cells not filled, plainly: whether a per-body multiplier on c or the core is eve Mapped and admitted (2026-10-14, base 11b2d7ad): `aetheria-gravity:cut_spec:cut-gravity-subtract.r2` and its sibling `aetheria-gravity:cut_spec:cut-orbits-placed-at-build.r1`, both merged at 5dd1f6b7. Mapped and admitted (2026-10-14, base 5dd1f6b7): `aetheria-gravity:cut_spec:cut-potential-owner.r3` (rebased on 3f5cef68 on 2026-10-15, after rulings `funnel-kept-until-flown` and `core-inside-roche-curve`), one Hands pass; it carries follow-ups `deep-orbit-placement-test` and `gravity-subtract-test-residuals`. Between it and grid-lowering the drawn grid lags (B23). The operator will fly the funnel before ruling on it again (`funnel-kept-until-flown`), so potential-owner and grid-lowering must both be on one playable branch before that flight; grid-lowering is stacked on potential-owner, not a sibling (B26), and its map is the next Imagination pass once potential-owner's report lands. Mapped and admitted (2026-10-15, base dd06f39d, stacked on potential-owner's head): `aetheria-gravity:cut_spec:cut-grid-lowering.r1`, one Hands pass, Starfire-verified; on its branch the drawn grid is the field (B30) and the Sector Waves noise is off the grid until grid-noise-presentation. Mapped and admitted (2026-10-15, base 8f491fd3, stacked on grid-lowering's head): `aetheria-gravity:cut_spec:cut-fog-precision-and-jitter.r1`, the slope jitter (B32) and the patch's second gravity lowering (B33), one Hands pass. Mapped and admitted (2026-10-15, base 8f491fd3, a sibling of fog-precision-and-jitter on grid-lowering's head, disjoint files): `aetheria-gravity:cut_spec:cut-orbits-from-field.r1`, periods from the field (B35-B38), one Hands pass; it raises `aetheria-gravity:question:rails-enclosed-or-parent` and is written for the recommended parent-only option. Not yet mapped: - **drawn-pose-at-drawn-time** (mapped and admitted 2026-10-15, base 09ec04a8: `aetheria-gravity:cut_spec:cut-drawn-pose-at-drawn-time.r1`, one Hands pass): `DrawAhead` owns every drawn pose at T = ExactTime + Lead (B39); closes item 3 of `fog-jitter-residuals`. -- **tail-taming** (ruling `tame-tails-parent-only-rails`, lean `tail-taming-lean-conserving-compact`): `aetheria-gravity:cut_spec:cut-well-law.r2` (the band law, B40-B43, B48; reach 1/2 D, series branch), runnable before question `tail-taming-law-r3` is answered; the generator rules go to children-within-reach once r3 sets the spacing. +- **tail-taming** (ruling `tame-tails-parent-only-rails`, lean `tail-taming-lean-conserving-compact`): `aetheria-gravity:cut_spec:cut-well-law.r2` (the band law, B40-B43, B48; reach 1/2 D, series branch), runnable before question `tail-taming-law-r3` is answered; the generator rules go to children-within-reach once r3 sets the spacing. Soul's s1 falsified it (multi-root far field, belt extent, unpinned fade start and ring edge, series threshold); the fix batch is `aetheria-gravity:cut_spec:cut-well-law.r3` (B52-B54), with the zone's shape between roots put to the operator as question `multi-root-zone-shape`. children-within-reach r2 needs a refresh after r3 lands (roots gain a finite reach, belts an extent). - **potential-owner** (mapped, above): one ServerShared potential (c from an authored compressed curve G·m^p, authored core-radius curve, uniform-disc core, log tail, superposition, analytic gradient, no gate, global K in PlanetSettings, reference zero at the root's zone radius); every CPU force reader (Ship, floating bodies, clouds, mines) and `Zone.Flow`'s slope term read it; GetHeight = φ/K; deletes `GravityAcceleration`, `GravityReferenceRate`, `GetNormal` as a force source, the PowerPulse well and `GravityDepthExponent`; field strength per field-strength-ruled. Waits on both admitted cuts. May split into the potential and its readers if one Hands pass cannot hold both. - **rosette-equilibria** (follow-up `rosette-generation-r3`, rulings `rosettes-artificial-derived-from-fields`, `rosettes-placed-in-equilibrium`, `rosettes-rare-and-huge`, `binaries-orbited-from-afar`): pairs and rosettes placed as relative equilibria under the band law, members on their own radii (a pair at r_i proportional to c_partner; twisted crowns for alternating rosettes), one spin for every member, so no ring shears (B51); WellTree's ring group accepts members on two radii; members always suns, rare and huge. Waits on children-within-reach (`cut-children-within-reach.r2`), which places rings as today's one radius. - **grid-noise-presentation**: the Sector Waves noise (after grid-lowering on layer 10, drawn only into the nebula patch height) and fine-grid detail move to non-slope presentation (grid-gravity-only-ruled); it also owns the Main Menu's own Surface Displacement brushes (B29, follow-up `menu-grid-brushes`). Waits on grid-lowering.