From 8f42265de33146deffaef437f2b8567e1db18263 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 14:18:41 +0200 Subject: [PATCH 001/769] Faction play 1: typed doctrine on Faction and one flight per faction per zone FactionDoctrine/RoleDoctrine (Faction key 17), Flight as the only writer of members' Target and Task, hail/grace/compliance, FollowTask/FollowState, agent removal on Entities removal, doctrine-catalog command, FlightTests. Not yet built or run; verification follows in later commits. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Agents/Agent.cs | 5 +- Assets/Scripts/ServerShared/Agents/Flight.cs | 178 ++++++++++ .../ServerShared/Agents/Flight.cs.meta | 2 + Assets/Scripts/ServerShared/Agents/Minion.cs | 23 +- .../ServerShared/Agents/States/Combat.cs | 5 +- .../ServerShared/Agents/States/Follow.cs | 26 ++ .../ServerShared/Agents/States/Follow.cs.meta | 2 + .../ServerShared/Agents/Tasks/AgentTask.cs | 3 +- .../ServerShared/Agents/Tasks/FollowTask.cs | 11 + .../Agents/Tasks/FollowTask.cs.meta | 2 + .../Behaviors/TurretController.cs | 2 +- Assets/Scripts/ServerShared/Corporations.cs | 6 + Assets/Scripts/ServerShared/Doctrine.cs | 74 ++++ Assets/Scripts/ServerShared/Doctrine.cs.meta | 2 + Assets/Scripts/ServerShared/Entity.cs | 3 + Assets/Scripts/ServerShared/FireControl.cs | 5 +- Assets/Scripts/ServerShared/Zone.cs | 25 +- .../FireControlPerWeaponTests.cs | 47 ++- .../Aetheria.Shared.Tests/FireControlTests.cs | 2 +- tests/Aetheria.Shared.Tests/FlightTests.cs | 332 ++++++++++++++++++ .../IffAndCombatTests.cs | 15 +- tests/Aetheria.Shared.Tests/MineTests.cs | 8 +- tools/AetherDb/DoctrineCatalog.cs | 69 ++++ tools/AetherDb/Program.cs | 7 +- 24 files changed, 812 insertions(+), 42 deletions(-) create mode 100644 Assets/Scripts/ServerShared/Agents/Flight.cs create mode 100644 Assets/Scripts/ServerShared/Agents/Flight.cs.meta create mode 100644 Assets/Scripts/ServerShared/Agents/States/Follow.cs create mode 100644 Assets/Scripts/ServerShared/Agents/States/Follow.cs.meta create mode 100644 Assets/Scripts/ServerShared/Agents/Tasks/FollowTask.cs create mode 100644 Assets/Scripts/ServerShared/Agents/Tasks/FollowTask.cs.meta create mode 100644 Assets/Scripts/ServerShared/Doctrine.cs create mode 100644 Assets/Scripts/ServerShared/Doctrine.cs.meta create mode 100644 tests/Aetheria.Shared.Tests/FlightTests.cs create mode 100644 tools/AetherDb/DoctrineCatalog.cs diff --git a/Assets/Scripts/ServerShared/Agents/Agent.cs b/Assets/Scripts/ServerShared/Agents/Agent.cs index 1d51a855f..b1900a3f8 100644 --- a/Assets/Scripts/ServerShared/Agents/Agent.cs +++ b/Assets/Scripts/ServerShared/Agents/Agent.cs @@ -23,11 +23,14 @@ public class Agent public Ship Ship { get; } public ItemManager ItemManager { get; } public GameplaySettings Settings { get; } + // How this pilot fights: its faction's doctrine for its role (Zone.CreateAgent resolves it). + public RoleDoctrine Doctrine { get; } public float TopSpeed => _velocityLimit?.Limit ?? 100; - public Agent(Ship ship) + public Agent(Ship ship, RoleDoctrine doctrine) { Ship = ship; + Doctrine = doctrine; ItemManager = Ship.ItemManager; Settings = Ship.ItemManager.GameplaySettings; diff --git a/Assets/Scripts/ServerShared/Agents/Flight.cs b/Assets/Scripts/ServerShared/Agents/Flight.cs new file mode 100644 index 000000000..1e6cccce1 --- /dev/null +++ b/Assets/Scripts/ServerShared/Agents/Flight.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CultMath; +using static CultMath.math; + +// One flight per (zone, faction) of piloted ships; unaffiliated ships form one flight of their own. The flight alone +// decides what its members do: which hostile each engages (Ship.Target) and what each does otherwise (Agent.Task). +// The agent graph executes those two and decides nothing. The faction's doctrine (FactionDoctrine) holds the numbers. +// +// The picture is the union of the members' VisibleEnemies. A track passes through phases the flight owns, runtime only: +// None, Hailing (grace running, one hail spoken), Complied (slow enough at the grace's end: followed, not engaged) and +// Engaged. A member still fires only at what its own VisibleEntities hold (FireControl), so the shared picture never +// lets it fire at what it cannot see. +public class Flight +{ + public enum Phase { None, Hailing, Complied, Engaged } + + private class Track + { + public Phase Phase; + public double Start; + } + + private class Member + { + public Agent Agent; + public PatrolOrbitsTask Patrol; + } + + private readonly FactionDoctrine _doctrine; + private readonly GameplaySettings _settings; + private readonly List _members = new List(); + private readonly Dictionary _tracks = new Dictionary(); + private double _time; + + // No doctrine fights as FactionDoctrine.Default; a doctrine that leaves a role unauthored gets that role's default. + public Flight(FactionDoctrine doctrine, GameplaySettings settings) + { + var fallback = FactionDoctrine.Default(settings); + _doctrine = doctrine ?? fallback; + _settings = settings; + Combatant = _doctrine.Combatant ?? fallback.Combatant; + } + + public RoleDoctrine Combatant { get; } + public int Count => _members.Count; + public bool Contains(Agent agent) => _members.Any(m => m.Agent == agent); + + public Phase PhaseOf(Entity track) => _tracks.TryGetValue(track, out var t) ? t.Phase : Phase.None; + + public void Join(Agent agent, PatrolOrbitsTask circuit) + { + _members.Add(new Member { Agent = agent, Patrol = circuit }); + agent.Task = circuit; + } + + public void Leave(Agent agent) => _members.RemoveAll(m => m.Agent == agent); + + public void Update(float dt) + { + _time += dt; + var picture = _members.SelectMany(m => m.Agent.Ship.VisibleEnemies).Distinct().ToList(); + foreach (var gone in _tracks.Keys.Where(t => !picture.Contains(t)).ToList()) _tracks.Remove(gone); + + foreach (var entity in picture) + { + if (!_tracks.TryGetValue(entity, out var track)) _tracks[entity] = track = new Track(); + Advance(entity, track); + } + + foreach (var member in _members) Order(member, picture); + } + + // A grudge is a hostile IFF override that any member holds on the track. + private bool Grudged(Entity entity) => _members.Any(m => m.Agent.Ship.IffOverride(entity) == true); + + private bool Engageable(Entity entity, bool grudge) + { + switch (_doctrine.EngageOn) + { + case EngageOn.Never: return false; + case EngageOn.Detection: return true; + case EngageOn.Identified: + return grudge || _members.Any(m => m.Agent.Ship.EntityInfoGathered.TryGetValue(entity, out var info) && + info >= _settings.TargetGearInfoThreshold); + case EngageOn.Provoked: return grudge; + case EngageOn.Trespass: return grudge || !(entity.PresencePermitted?.Value ?? true); + default: return false; + } + } + + private void Advance(Entity entity, Track track) + { + if (track.Phase == Phase.Engaged) return; + var grudge = Grudged(entity); + if (!Engageable(entity, grudge)) + { + track.Phase = Phase.None; + return; + } + + // A grudge is already a quarrel: no grace to give. + if (grudge) + { + track.Phase = Phase.Engaged; + return; + } + + switch (track.Phase) + { + case Phase.None: + if (_doctrine.Grace > 0) + { + track.Phase = Phase.Hailing; + track.Start = _time; + if (!string.IsNullOrEmpty(_doctrine.Hail)) Nearest(entity)?.Agent.Ship.SetMessage(_doctrine.Hail); + } + else track.Phase = Phase.Engaged; + break; + case Phase.Hailing: + if (_time >= track.Start + _doctrine.Grace) + track.Phase = _doctrine.ComplySpeed > 0 && length(entity.Velocity) < _doctrine.ComplySpeed + ? Phase.Complied + : Phase.Engaged; + break; + case Phase.Complied: + if (length(entity.Velocity) >= _doctrine.ComplySpeed) track.Phase = Phase.Engaged; + break; + } + } + + private Member Nearest(Entity entity) => + _members.OrderBy(m => lengthsq(m.Agent.Ship.Position.xz - entity.Position.xz)).FirstOrDefault(); + + private void Order(Member member, List picture) + { + var ship = member.Agent.Ship; + var seen = ship.VisibleEnemies; + + // Engage: keep the current target while it stays engaged, else the nearest engaged track this member can see. + var engaged = picture.Where(e => _tracks[e].Phase == Phase.Engaged && seen.Contains(e)).ToList(); + if (engaged.Count > 0) + { + var current = ship.Target.Value.Entity; + var pick = current != null && engaged.Contains(current) + ? current + : engaged.OrderBy(e => lengthsq(ship.Position.xz - e.Position.xz)).First(); + Aim(ship, pick); + return; + } + + Aim(ship, null); + var held = picture.Where(e => _tracks[e].Phase == Phase.Hailing || _tracks[e].Phase == Phase.Complied).ToList(); + if (held.Count > 0) + { + var anchor = member.Agent.Task is FollowTask follow && held.Contains(follow.Anchor) + ? follow.Anchor + : held.OrderBy(e => lengthsq(ship.Position.xz - e.Position.xz)).First(); + if (!(member.Agent.Task is FollowTask current) || current.Anchor != anchor) + member.Agent.Task = new FollowTask { Anchor = anchor, Standoff = Standoff(ship) }; + } + else if (member.Agent.Task != member.Patrol) + member.Agent.Task = member.Patrol; + } + + private static void Aim(Ship ship, Entity target) + { + var wanted = target == null ? TargetRef.None : new TargetRef(target); + if (!ship.Target.Value.Equals(wanted)) ship.SetTarget(wanted); + } + + private float Standoff(Ship ship) + { + if (Combatant.HoldStandoff > 0) return Combatant.HoldStandoff; + return 1.1f * ship.Weapons.Select(w => w.Range).DefaultIfEmpty(0f).Max(); + } +} diff --git a/Assets/Scripts/ServerShared/Agents/Flight.cs.meta b/Assets/Scripts/ServerShared/Agents/Flight.cs.meta new file mode 100644 index 000000000..bcbf8c60e --- /dev/null +++ b/Assets/Scripts/ServerShared/Agents/Flight.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5f6c82a74e5b4bc1b8c79e0d242e3ec2 diff --git a/Assets/Scripts/ServerShared/Agents/Minion.cs b/Assets/Scripts/ServerShared/Agents/Minion.cs index 7540cb5c8..80ee3c34f 100644 --- a/Assets/Scripts/ServerShared/Agents/Minion.cs +++ b/Assets/Scripts/ServerShared/Agents/Minion.cs @@ -1,23 +1,30 @@ - using System.Linq; using UniRx; +// The agent graph for a piloted ship. It executes the Task and Target its flight writes (Flight.cs) and decides +// neither: patrol and follow follow the Task kind, combat follows the Target. public class Minion : Agent { - public Minion(Ship ship) : base(ship) + public Minion(Ship ship, RoleDoctrine doctrine) : base(ship, doctrine) { var patrolState = new PatrolOrbitsState(this); - _rootState.AddTransition(patrolState, - () => Task is PatrolOrbitsTask, - () => patrolState.Task = Task as PatrolOrbitsTask); + var followState = new FollowState(this); + void Patrol() => patrolState.Task = Task as PatrolOrbitsTask; + _rootState.AddTransition(patrolState, () => Task is PatrolOrbitsTask, Patrol); + _rootState.AddTransition(followState, () => Task is FollowTask); - Ship.VisibleEnemies.ObserveAdd().Where(_ => Ship.Target.Value.IsNone).Subscribe(add => Ship.SetTarget(add.Value)); + // A Task change switches the substate in one update, from wherever the pilot is in the old task. includeChildren + // reaches the sub-states (MoveToOrbit) that Update actually sits in. + patrolState.AddTransition(followState, () => Task is FollowTask, null, true, _rootState); + followState.AddTransition(patrolState, () => Task is PatrolOrbitsTask, Patrol, true, _rootState); + patrolState.AddTransition(_rootState, () => !(Task is PatrolOrbitsTask), null, true, _rootState); + followState.AddTransition(_rootState, () => !(Task is FollowTask), null, true, _rootState); var combatState = new CombatState(this); _rootState.AddTransition(combatState, () => Ship.Target.Value.Entity != null, null, true, _rootState); - + combatState.AddTransition(_rootState, () => Ship.Target.Value.Entity == null, null, true, _rootState); } -} \ No newline at end of file +} diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index 092c6af4c..b64cd7d06 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -11,6 +11,7 @@ public class CombatState : BaseState { private const int DPS_SAMPLE_COUNT = 32; private float _optimumRange; + public float OptimumRange => _optimumRange; private readonly List<(int index, float dps)> _availableGroups = new List<(int index, float dps)>(); private readonly List _availableLockingWeapons = new List(); @@ -118,7 +119,7 @@ public class CombatState : BaseState // fires at, and "the target is designated" for a fused one. foreach (var weapon in _agent.Ship.WeaponGroups[selectedGroup].weapons) { - if (FireControl.AgentFires(weapon, _agent.Ship, target)) + if (FireControl.AgentFires(weapon, _agent.Ship, target, _agent.Doctrine.MinHitProbability)) weapon.Activate(); else if (weapon.Firing) weapon.Deactivate(); @@ -156,7 +157,7 @@ public class CombatState : BaseState } // Multiply by range raised to a (sublinear) exponent to bias DPS preference towards longer ranges - dps *= pow(range, _agent.Settings.AgentRangeExponent); + dps *= pow(range, _agent.Doctrine.RangeExponent); if (dps > optimumDPS) { diff --git a/Assets/Scripts/ServerShared/Agents/States/Follow.cs b/Assets/Scripts/ServerShared/Agents/States/Follow.cs new file mode 100644 index 000000000..908ecd257 --- /dev/null +++ b/Assets/Scripts/ServerShared/Agents/States/Follow.cs @@ -0,0 +1,26 @@ +using CultMath; +using static CultMath.math; +using float2 = CultMath.float2; + +// Hold a standoff from the anchor: match its velocity, and close or open the gap in proportion to the error. +public class FollowState : BaseState +{ + public FollowState(Agent agent) : base(agent) { } + + public override void Update(float delta) + { + var task = _agent.Task as FollowTask; + if (task?.Anchor == null) return; + + var toAnchor = task.Anchor.Position.xz - _agent.Ship.Position.xz; + var distance = length(toAnchor); + var direction = distance > 0 ? toAnchor / distance : _agent.Ship.Direction; + // Positive error: too far, close. Negative: too near, open. + var error = distance - task.Standoff; + var pull = direction * (float) sign(error) * saturate(abs(error) / max(task.Standoff, 1f)); + var desired = task.Anchor.Velocity + pull * _agent.TopSpeed; + + _agent.Ship.Aim = float3(direction.x, 0, direction.y); + _agent.Accelerate(desired); + } +} diff --git a/Assets/Scripts/ServerShared/Agents/States/Follow.cs.meta b/Assets/Scripts/ServerShared/Agents/States/Follow.cs.meta new file mode 100644 index 000000000..f44ed42c5 --- /dev/null +++ b/Assets/Scripts/ServerShared/Agents/States/Follow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5cf86244ca6648a99f304b061b66348a diff --git a/Assets/Scripts/ServerShared/Agents/Tasks/AgentTask.cs b/Assets/Scripts/ServerShared/Agents/Tasks/AgentTask.cs index 26b70716c..9b5a6d773 100644 --- a/Assets/Scripts/ServerShared/Agents/Tasks/AgentTask.cs +++ b/Assets/Scripts/ServerShared/Agents/Tasks/AgentTask.cs @@ -29,6 +29,7 @@ public enum TaskType Tow, Defend, Attack, - Explore + Explore, + Follow } diff --git a/Assets/Scripts/ServerShared/Agents/Tasks/FollowTask.cs b/Assets/Scripts/ServerShared/Agents/Tasks/FollowTask.cs new file mode 100644 index 000000000..4e09d0a35 --- /dev/null +++ b/Assets/Scripts/ServerShared/Agents/Tasks/FollowTask.cs @@ -0,0 +1,11 @@ +using System; +using MessagePack; + +// Keep station on an entity at a standoff: the flight hailing a track holds its members here (Flight.cs). +// Runtime only, like every agent task. +public class FollowTask : AgentTask +{ + [IgnoreMember] public Entity Anchor; + [IgnoreMember] public float Standoff; + [IgnoreMember] public override TaskType Type => TaskType.Follow; +} diff --git a/Assets/Scripts/ServerShared/Agents/Tasks/FollowTask.cs.meta b/Assets/Scripts/ServerShared/Agents/Tasks/FollowTask.cs.meta new file mode 100644 index 000000000..009e9c4b1 --- /dev/null +++ b/Assets/Scripts/ServerShared/Agents/Tasks/FollowTask.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6bab02326ff64e29ad2b81f0fc1483b5 diff --git a/Assets/Scripts/ServerShared/Behaviors/TurretController.cs b/Assets/Scripts/ServerShared/Behaviors/TurretController.cs index 654ee5ec1..be092f7a0 100644 --- a/Assets/Scripts/ServerShared/Behaviors/TurretController.cs +++ b/Assets/Scripts/ServerShared/Behaviors/TurretController.cs @@ -80,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, target)) + if (FireControl.AgentFires(x, Entity, target, Entity.ItemManager.GameplaySettings.AgentMinHitProbability)) { x.Activate(); } diff --git a/Assets/Scripts/ServerShared/Corporations.cs b/Assets/Scripts/ServerShared/Corporations.cs index 45edba408..c1e69fcca 100644 --- a/Assets/Scripts/ServerShared/Corporations.cs +++ b/Assets/Scripts/ServerShared/Corporations.cs @@ -57,6 +57,12 @@ public class Faction [Inspectable, JsonProperty("bossMusic"), Key(15)] public uint BossMusic; + + // Key 16 is reserved for Relations (operator ruling faction-relations-field). + + // The faction's rules of engagement; null fights as FactionDoctrine.Default. + [Inspectable, JsonProperty("doctrine"), Key(17)] + public FactionDoctrine Doctrine; } [CultDocument("aetheria.namefile", "1"), Inspectable, MessagePackObject] diff --git a/Assets/Scripts/ServerShared/Doctrine.cs b/Assets/Scripts/ServerShared/Doctrine.cs new file mode 100644 index 000000000..40ce188e0 --- /dev/null +++ b/Assets/Scripts/ServerShared/Doctrine.cs @@ -0,0 +1,74 @@ +/* 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 MessagePack; +using Newtonsoft.Json; + +// What lets a flight open fire. The flight reads it per track; nothing else does. +public enum EngageOn +{ + // Any hostile the flight's members can see. + Detection, + // A hostile whose gear one member has resolved (EntityInfoGathered at or past TargetGearInfoThreshold). + Identified, + // Only a hostile that a member holds a hostile IFF override on (a grudge). + Provoked, + // A hostile whose presence this faction's zone does not permit. + Trespass, + // Never, not even for a grudge. + Never +} + +// A faction's rules of engagement (operator ruling faction-doctrine-typed): flight-level fields plus one RoleDoctrine +// per role. Keys 5-8 are reserved for faction-play-2, keys 9-10 for faction-play-3. A faction with no doctrine gets +// Default, which fights as the agents did before doctrine existed. +[MessagePackObject, JsonObject(MemberSerialization.OptIn)] +public class FactionDoctrine +{ + [Inspectable, JsonProperty("engageOn"), Key(0)] + public EngageOn EngageOn; + + // Seconds between a flight hailing a track and opening fire on it. Zero fires at once, with no hail. + [Inspectable, JsonProperty("grace"), Key(1)] + public float Grace; + + [Inspectable, JsonProperty("hail"), Key(2)] + public string Hail; + + // A hailed track slower than this at the end of the grace counts as complying and is followed, not engaged. + // Zero means nothing complies. + [Inspectable, JsonProperty("complySpeed"), Key(3)] + public float ComplySpeed; + + [Inspectable, JsonProperty("combatant"), Key(4)] + public RoleDoctrine Combatant; + + public static FactionDoctrine Default(GameplaySettings settings) => new FactionDoctrine + { + EngageOn = EngageOn.Detection, + Combatant = new RoleDoctrine + { + RangeExponent = settings.AgentRangeExponent, + MinHitProbability = settings.AgentMinHitProbability + } + }; +} + +// How one role fights. Combatant is the only role so far. +[MessagePackObject, JsonObject(MemberSerialization.OptIn)] +public class RoleDoctrine +{ + // Bias of the preferred engagement range towards longer ranges (CombatState's optimum range). + [Inspectable, JsonProperty("rangeExponent"), Key(0)] + public float RangeExponent; + + // A weapon fires only when its hit probability reaches this. + [Inspectable, JsonProperty("minHitProbability"), Key(1)] + public float MinHitProbability; + + // Distance a flight holds from a track it is hailing or following. Zero means 1.1 x the member's longest weapon range. + [Inspectable, JsonProperty("holdStandoff"), Key(2)] + public float HoldStandoff; +} diff --git a/Assets/Scripts/ServerShared/Doctrine.cs.meta b/Assets/Scripts/ServerShared/Doctrine.cs.meta new file mode 100644 index 000000000..40a273709 --- /dev/null +++ b/Assets/Scripts/ServerShared/Doctrine.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3f9a1c5e7b2d4e68a0c4d1b9e7f25a31 diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 60b84ce87..e7a4b853f 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -814,6 +814,9 @@ public abstract class Entity else _iffOverrides.Remove(other); } + // The override this entity holds on another, or null when it holds none (the derived rule decides). + public bool? IffOverride(Entity other) => _iffOverrides.TryGetValue(other, out var hostile) ? hostile : (bool?) null; + public bool IsHostileTo(Entity other, bool recursive = false) { // An override decides only the stance of the entity that holds it. The recursive=true calls diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 50b8a3b7f..f90e41ae3 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -101,12 +101,13 @@ public static class FireControl // target is designated, whatever the arc: the round is sent out of arc on purpose (Solve). An agent never // fires a fused weapon at nothing -- no target, or one it holds no valid data on, is no reason to fish -- and // never one Solve refuses: a refused weapon is not a weapon it can use. - public static bool AgentFires(Weapon weapon, Entity shooter, Entity target) + // The hit-probability threshold is the shooter's doctrine (RoleDoctrine.MinHitProbability), or the turret's global. + public static bool AgentFires(Weapon weapon, Entity shooter, Entity target, float minHitProbability) { // A mine layer lays only at a designated target in range, never at nothing and never on a hit probability. if (IsMineLayer(weapon)) return Solve(weapon, weapon.Item, shooter, target).Designated; if (FuseOf(weapon.Item, out _) == null) - return HitProbability(weapon, shooter, target) >= shooter.ItemManager.GameplaySettings.AgentMinHitProbability; + return HitProbability(weapon, shooter, target) >= minHitProbability; var solution = Solve(weapon, weapon.Item, shooter, target); return solution.Designated && solution.Outcome != FireOutcome.Refused; } diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 75f278e34..1ad7888fb 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -38,6 +38,11 @@ public class Zone private ItemManager _itemManager; private double _time; public List Agents = new List(); + // One flight per faction, created on its first piloted join; unaffiliated ships share Unaffiliated. A flight owns + // what its members target and do (Agents/Flight.cs). + private static readonly Faction Unaffiliated = new Faction(); + private readonly Dictionary _flights = new Dictionary(); + public IEnumerable Flights => _flights.Values; // 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. @@ -106,6 +111,16 @@ 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(); })); + // Leaving Entities, by death, docking or a transfer, is leaving the zone's pilots: the agent and its flight + // membership go with the ship, so nothing keeps flying a hull that is not here. + Entities.ObserveRemove().Subscribe(remove => + { + var agent = Agents.FirstOrDefault(a => a.Ship == remove.Value); + if (agent == null) return; + Agents.Remove(agent); + foreach (var flight in _flights.Values) flight.Leave(agent); + }); + Targets.Add(new EntityTargets()); foreach (var orbit in pack.Orbits) @@ -173,10 +188,13 @@ public class Zone private Agent CreateAgent(Ship ship) { - var agent = new Minion(ship); + var key = ship.Faction ?? Unaffiliated; + if (!_flights.TryGetValue(key, out var flight)) + _flights[key] = flight = new Flight(ship.Faction?.Doctrine, _itemManager.GameplaySettings); + var agent = new Minion(ship, flight.Combatant); var task = new PatrolOrbitsTask(); task.Circuit = Orbits.OrderBy(_ => _itemManager.Random.NextFloat()).Take(4).Select(x => x.Key).ToArray(); - agent.Task = task; + flight.Join(agent, task); return agent; } @@ -218,7 +236,8 @@ public class Zone orbit.Value.Velocity = (orbit.Value.Position - orbit.Value.PreviousPosition) / deltaTime; } - foreach(var agent in Agents) + foreach (var flight in _flights.Values) flight.Update(deltaTime); + foreach(var agent in Agents.ToArray()) agent.Update(deltaTime); foreach (var entity in Entities.ToArray()) entity.Update(deltaTime); diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 60bc55e00..49a319e56 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -273,7 +273,7 @@ public sealed class FireControlPerWeaponTests : IDisposable var r = Build(SideThenForward); var group = r.Shooter.WeaponGroups.Single(g => g.weapons.Count == 2).weapons; Assert.True(ReferenceEquals(group[0], r.Guns[0].Weapon), "fixture: the side mount leads the group"); - r.Zone.Agents.Add(new Minion(r.Shooter)); + r.Zone.Agents.Add(new Minion(r.Shooter, FactionDoctrine.Default(r.Shooter.ItemManager.GameplaySettings).Combatant)); r.Zone.Update(1f); r.Zone.Update(1f); @@ -288,7 +288,7 @@ public sealed class FireControlPerWeaponTests : IDisposable public void CombatFiresAFusedWeaponAtADesignatedTargetOutOfArc() { var r = Build(SideThenForward, fuse: WeaponFuse.Proximity, blast: 4f); - r.Zone.Agents.Add(new Minion(r.Shooter)); + r.Zone.Agents.Add(new Minion(r.Shooter, FactionDoctrine.Default(r.Shooter.ItemManager.GameplaySettings).Combatant)); r.Zone.Update(1f); r.Zone.Update(1f); @@ -328,7 +328,7 @@ public sealed class FireControlPerWeaponTests : IDisposable if (reason == Withheld.NoTarget) target = null; if (reason == Withheld.NotVisible) r.Shooter.VisibleEntities.Remove(r.Target); - Assert.False(FireControl.AgentFires(r.Gun.Weapon, r.Shooter, target)); + Assert.False(FireControl.AgentFires(r.Gun.Weapon, r.Shooter, target, r.Shooter.ItemManager.GameplaySettings.AgentMinHitProbability)); } [Fact] @@ -337,7 +337,7 @@ public sealed class FireControlPerWeaponTests : IDisposable // Accuracy 0 prices every direct hit at zero; the fused weapon is unmoved. var r = Build(SideMount, fuse: WeaponFuse.Proximity, blast: 4f, accuracy: 0f); - Assert.True(FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target)); + Assert.True(FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target, r.Shooter.ItemManager.GameplaySettings.AgentMinHitProbability)); } // A weapon that fires at what it hits keeps the AgentMinHitProbability threshold, per weapon. @@ -351,7 +351,7 @@ public sealed class FireControlPerWeaponTests : IDisposable var p = FireControl.HitProbability(r.Gun.Weapon, r.Shooter, r.Target); Assert.True(p > .3f && p < .7f, $"fixture: the shot must price near the accuracy, priced {p}"); - Assert.Equal(fires, FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target)); + Assert.Equal(fires, FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target, r.Shooter.ItemManager.GameplaySettings.AgentMinHitProbability)); } // ==== Ruling 2: the HUD forecast is Fire's own decision ==== @@ -566,7 +566,7 @@ public sealed class FireControlPerWeaponTests : IDisposable { var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range, targetRange: 20f); - Assert.Equal(fires, FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target)); + Assert.Equal(fires, FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target, r.Shooter.ItemManager.GameplaySettings.AgentMinHitProbability)); } // Combat's group pick counts only weapons that can fire: the refused gun's group out-damages the decoy's, so a @@ -577,7 +577,7 @@ public sealed class FireControlPerWeaponTests : IDisposable { var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: 29.5f, targetRange: 20f, decoy: true); Assert.True(r.Gun.Weapon.RangeDamagePerSecond(20f) > r.Decoy.Weapon.RangeDamagePerSecond(20f), "fixture: the refused gun is the stronger group"); - r.Zone.Agents.Add(new Minion(r.Shooter)); + r.Zone.Agents.Add(new Minion(r.Shooter, FactionDoctrine.Default(r.Shooter.ItemManager.GameplaySettings).Combatant)); r.Zone.Update(1f); r.Zone.Update(1f); @@ -594,7 +594,7 @@ public sealed class FireControlPerWeaponTests : IDisposable { var r = Build(range: 10f, targetRange: 20f, decoy: true); Assert.True(r.Gun.Weapon.RangeDamagePerSecond(20f) > r.Decoy.Weapon.RangeDamagePerSecond(20f), "fixture: the short gun is the stronger group"); - r.Zone.Agents.Add(new Minion(r.Shooter)); + r.Zone.Agents.Add(new Minion(r.Shooter, FactionDoctrine.Default(r.Shooter.ItemManager.GameplaySettings).Combatant)); r.Zone.Update(1f); r.Zone.Update(1f); @@ -611,7 +611,7 @@ public sealed class FireControlPerWeaponTests : IDisposable var r = Build(range: 60f, targetRange: 60f); // The agent is driven directly, not through the zone, so the shooter stays exactly at max range: the first // update leaves the root state for combat, the second is combat deciding. - var agent = new Minion(r.Shooter); + var agent = new Minion(r.Shooter, FactionDoctrine.Default(r.Shooter.ItemManager.GameplaySettings).Combatant); agent.Update(.01f); agent.Update(.01f); @@ -630,8 +630,33 @@ public sealed class FireControlPerWeaponTests : IDisposable var atThreshold = Build(accuracy: .5f, minHit: price); var overThreshold = Build(accuracy: .5f, minHit: MathF.BitIncrement(price)); - Assert.True(FireControl.AgentFires(atThreshold.Gun.Weapon, atThreshold.Shooter, atThreshold.Target)); - Assert.False(FireControl.AgentFires(overThreshold.Gun.Weapon, overThreshold.Shooter, overThreshold.Target)); + Assert.True(FireControl.AgentFires(atThreshold.Gun.Weapon, atThreshold.Shooter, atThreshold.Target, atThreshold.Shooter.ItemManager.GameplaySettings.AgentMinHitProbability)); + Assert.False(FireControl.AgentFires(overThreshold.Gun.Weapon, overThreshold.Shooter, overThreshold.Target, overThreshold.Shooter.ItemManager.GameplaySettings.AgentMinHitProbability)); + } + + // The threshold is the doctrine's, passed in: the settings global (zero here) decides nothing for a pilot. Kills: a + // reintroduced read of AgentMinHitProbability inside AgentFires. + [Fact] + public void TheDoctrinesThresholdDecidesFire() + { + var r = Build(accuracy: .5f, minHit: 0f); + var price = FireControl.HitProbability(r.Gun.Weapon, r.Shooter, r.Target); + Assert.True(price > 0f); + + Assert.True(FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target, price)); + Assert.False(FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target, MathF.BitIncrement(price))); + } + + // A long and a short weapon: the exponent biases the preferred range outward. Kills: the optimum read from the + // settings global instead of the member's RoleDoctrine. + [Fact] + public void TheDoctrinesExponentMovesTheOptimumRange() + { + var r = Build(range: 400f, decoy: true); + float Optimum(float exponent) => + new CombatState(new Minion(r.Shooter, new RoleDoctrine { RangeExponent = exponent, MinHitProbability = .2f })).OptimumRange; + + Assert.True(Optimum(0f) < Optimum(1f)); } // A round refused on a Range that fell between the trigger and the round is still free: the burst is paid diff --git a/tests/Aetheria.Shared.Tests/FireControlTests.cs b/tests/Aetheria.Shared.Tests/FireControlTests.cs index a6d40a089..6dd66fbcb 100644 --- a/tests/Aetheria.Shared.Tests/FireControlTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlTests.cs @@ -211,7 +211,7 @@ public sealed class FireControlTests : IDisposable // decision at all (Cut 1's regression), not AgentMinHitProbability tuning -- zero the threshold so an // unaided shot still counts as "worth it." items.GameplaySettings.AgentMinHitProbability = 0f; - zone.Agents.Add(new Minion(shooter)); + zone.Agents.Add(new Minion(shooter, FactionDoctrine.Default(shooter.ItemManager.GameplaySettings).Combatant)); return (shooter, target, zone); } diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs new file mode 100644 index 000000000..c049c9d43 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -0,0 +1,332 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using CultMath; +using GameCult.Caching; +using UniRx; +using Xunit; + +// The flight (Agents/Flight.cs) is the only writer of its members' Target and Task. These tests drive it on the +// IffAndCombatTests world. Perception is the sensors' output (VisibleEnemies, EntityInfoGathered), which +// IffAndCombatTests covers separately, so the tests set it directly; a grudge is a real IFF override. +public sealed class FlightTests : IDisposable +{ + private readonly IffAndCombatTests _world = new IffAndCombatTests(); + private readonly ItemManager _items; + private readonly Zone _zone; + + public FlightTests() => (_items, _zone) = _world.BuildWorld(); + + public void Dispose() => _world.Dispose(); + + private static readonly Faction Hostile = new Faction { Name = "Hostile" }; + + private static Faction WithDoctrine(EngageOn on, float grace = 0, string hail = null, float comply = 0) => new Faction + { + Name = "Escort", + Doctrine = new FactionDoctrine + { + EngageOn = on, Grace = grace, Hail = hail, ComplySpeed = comply, + Combatant = new RoleDoctrine { RangeExponent = .25f, MinHitProbability = .2f } + } + }; + + private Ship Pilot(Faction faction, float x = 0) + { + var ship = _world.NewShip(_items, _zone, faction, piloted: true); + ship.Position = new float3(x, 0, 0); + return ship; + } + + private Ship Track(float x = 100) + { + var track = _world.NewShip(_items, _zone, Hostile); + track.Position = new float3(x, 0, 0); + return track; + } + + private static void See(Ship member, Entity track) + { + member.EntityInfoGathered[track] = 1; + member.VisibleEnemies.Add(track); + } + + private void Tick(float dt) + { + foreach (var flight in _zone.Flights.ToArray()) flight.Update(dt); + } + + [Fact] + public void ADefaultDoctrineEngagesTheVisibleEnemyAsMinionDid() + { + var member = Pilot(new Faction { Name = "Plain" }); + var track = Track(); + See(member, track); + + Tick(.1f); + Assert.Same(track, member.Target.Value.Entity); + + member.VisibleEnemies.Remove(track); + Tick(.1f); + Assert.True(member.Target.Value.IsNone); + Assert.IsType(_zone.Agents.Single().Task); + } + + [Fact] + public void OnlyTheFlightWritesTheTarget() + { + var member = Pilot(new Faction { Name = "Plain" }); + See(member, Track()); + + Assert.True(member.Target.Value.IsNone); + Tick(.1f); + Assert.False(member.Target.Value.IsNone); + } + + [Fact] + public void IdentifiedWaitsForTheGearTier() + { + _items.GameplaySettings.TargetGearInfoThreshold = .8f; + var faction = WithDoctrine(EngageOn.Identified); + var a = Pilot(faction); + var b = Pilot(faction, 10); + var track = Track(); + See(a, track); + See(b, track); + a.EntityInfoGathered[track] = .7f; + b.EntityInfoGathered[track] = .7f; + + Tick(.1f); + Assert.True(a.Target.Value.IsNone); + Assert.True(b.Target.Value.IsNone); + + b.EntityInfoGathered[track] = .9f; + Tick(.1f); + Assert.Same(track, a.Target.Value.Entity); + Assert.Same(track, b.Target.Value.Entity); + } + + [Fact] + public void ProvokedEngagesOnlyTheGrudgedTrack() + { + var member = Pilot(WithDoctrine(EngageOn.Provoked)); + var calm = Track(80); + var grudged = Track(120); + See(member, calm); + See(member, grudged); + member.SetIff(grudged, true); + + Tick(.1f); + + Assert.Same(grudged, member.Target.Value.Entity); + } + + [Fact] + public void NeverIgnoresEvenAGrudge() + { + var member = Pilot(WithDoctrine(EngageOn.Never)); + var track = Track(); + See(member, track); + member.SetIff(track, true); + + Tick(.1f); + + Assert.True(member.Target.Value.IsNone); + } + + [Fact] + public void GraceHailsOnceFromTheNearestThenEngages() + { + var faction = WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold."); + var near = Pilot(faction, 90); + var far = Pilot(faction, 0); + var track = Track(100); + See(near, track); + See(far, track); + + Tick(.1f); + + Assert.True(near.Messages.ContainsKey("Hold.")); + Assert.False(far.Messages.ContainsKey("Hold.")); + Assert.True(near.Target.Value.IsNone && far.Target.Value.IsNone); + Assert.All(_zone.Agents, a => Assert.IsType(a.Task)); + + Tick(4.8f); + Assert.True(near.Target.Value.IsNone && far.Target.Value.IsNone); + + Tick(.4f); + Assert.Same(track, near.Target.Value.Entity); + Assert.Same(track, far.Target.Value.Entity); + } + + [Fact] + public void ASlowTrackComplies() + { + var faction = WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold.", comply: 10); + var member = Pilot(faction); + var track = Track(); + See(member, track); + + Tick(.1f); + Tick(5.1f); + Assert.True(member.Target.Value.IsNone); + Assert.IsType(_zone.Agents.Single().Task); + + member.Messages.Clear(); + track.Velocity = new float2(20, 0); + Tick(.1f); + Assert.Same(track, member.Target.Value.Entity); + Assert.Empty(member.Messages); + } + + [Fact] + public void AGrudgeSkipsTheGrace() + { + var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold.")); + var track = Track(); + See(member, track); + + Tick(.1f); + Assert.True(member.Target.Value.IsNone); + + member.SetIff(track, true); + Tick(.1f); + Assert.Same(track, member.Target.Value.Entity); + } + + [Fact] + public void FollowClosesWhenFarAndOpensWhenNear() + { + var far = Pilot(null); + var near = Pilot(null); + var anchor = Track(); + + float TurnAfterFollowing(Ship ship, float3 anchorAt) + { + anchor.Position = anchorAt; + var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) + { Task = new FollowTask { Anchor = anchor, Standoff = 100 } }; + minion.Update(.1f); + minion.Update(.1f); + return ship.Turn; + } + + // The hull faces +z. Anchor 300 ahead: the desired velocity is dead ahead, no turn. Anchor 20 ahead: inside the + // standoff, the desired velocity is astern, and the pilot turns to it. + Assert.Equal(0, TurnAfterFollowing(far, new float3(0, 0, 300)), 3); + Assert.NotEqual(0, TurnAfterFollowing(near, new float3(0, 0, 20)), 3); + } + + [Fact] + public void ATaskChangeLeavesThePatrolSubstate() + { + var ship = Pilot(null); + var anchor = Track(); + var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) + { Task = new PatrolOrbitsTask { Circuit = new CultRecordKey[1] } }; + for (var i = 0; i < 4; i++) minion.Update(.1f); + Assert.IsType(CurrentState(minion)); + + minion.Task = new FollowTask { Anchor = anchor, Standoff = 50 }; + minion.Update(.1f); + + Assert.IsType(CurrentState(minion)); + } + + private static BaseState CurrentState(Agent agent) => + (BaseState) typeof(Agent).GetField("_currentState", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(agent); + + [Fact] + public void RemovalTakesTheAgentAndTheFlightMember() + { + var faction = new Faction { Name = "Plain" }; + var dies = Pilot(faction); + var leaves = Pilot(faction, 10); + var stays = Pilot(faction, 20); + var player = _world.NewShip(_items, _zone, faction, isPlayerShip: true); + var flight = _zone.Flights.Single(); + Assert.Equal(3, flight.Count); + + dies.Hull.Durability = 0; + dies.HullDamage.OnNext(1f); + _zone.Entities.Remove(leaves); + + Assert.Equal(new[] { stays }, _zone.Agents.Select(a => a.Ship)); + Assert.Equal(1, flight.Count); + Assert.DoesNotContain(_zone.Agents, a => a.Ship == player); + } + + private static string TempCatalog() => Path.Combine(Path.GetTempPath(), "aetheria-doctrine-" + Guid.NewGuid().ToString("N") + ".cc"); + + [Fact] + public void TheDoctrineRoundTrips() + { + var path = TempCatalog(); + try + { + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(new Faction + { + Name = "Armed", + Doctrine = new FactionDoctrine + { + EngageOn = EngageOn.Trespass, Grace = 6, Hail = "Hold.", ComplySpeed = 10, + Combatant = new RoleDoctrine { RangeExponent = .05f, MinHitProbability = .15f, HoldStandoff = 40 } + } + }); + cache.Upsert(new Faction { Name = "Bare" }); + cache.FlushAsync().Wait(); + } + + using (var cache = AetheriaStores.Open(path)) + { + var armed = cache.GetAll().Single(f => f.Name == "Armed").Doctrine; + Assert.Equal(EngageOn.Trespass, armed.EngageOn); + Assert.Equal(6f, armed.Grace); + Assert.Equal("Hold.", armed.Hail); + Assert.Equal(10f, armed.ComplySpeed); + Assert.Equal(.05f, armed.Combatant.RangeExponent); + Assert.Equal(.15f, armed.Combatant.MinHitProbability); + Assert.Equal(40f, armed.Combatant.HoldStandoff); + Assert.Null(cache.GetAll().Single(f => f.Name == "Bare").Doctrine); + } + } + finally { File.Delete(path); } + } + + [Fact] + public void TheCatalogCommandNamesAMissingFaction() + { + var path = TempCatalog(); + try + { + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + foreach (var name in new[] { "Zhestokost", "Lucent Media", "Aeronautics Unlimited" }) cache.Upsert(new Faction { Name = name }); + cache.FlushAsync().Wait(); + } + + var output = new StringWriter(); + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + DoctrineCatalog.Run(cache, apply: true, output); + cache.FlushAsync().Wait(); + } + + Assert.Contains("Pirates: no Faction record, doctrine not written", output.ToString()); + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + Assert.All(cache.GetAll(), f => Assert.NotNull(f.Doctrine)); + Assert.Equal(EngageOn.Trespass, cache.GetAll().Single(f => f.Name == "Zhestokost").Doctrine.EngageOn); + cache.Upsert(new Faction { Name = "Pirates" }); + DoctrineCatalog.Run(cache, apply: true, new StringWriter()); + Assert.Equal(8f, cache.GetAll().Single(f => f.Name == "Pirates").Doctrine.Grace); + } + } + finally { File.Delete(path); } + } +} diff --git a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs index e77bfad51..0f7e4339b 100644 --- a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs +++ b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs @@ -48,7 +48,7 @@ public sealed class IffAndCombatTests : IDisposable // A world with one empty zone (no faction/security machinery engaged: GalaxyZone.Owner is null and // every test ship is non-player, so PresencePermitted stays at its default true and derived hostility // between non-owner factions is always false). Individual tests layer overrides/factions on top. - private (ItemManager items, Zone zone) BuildWorld() + internal (ItemManager items, Zone zone) BuildWorld() { _openCache = AetheriaStores.Open(Catalog, catalogWritable: true); var ledger = new ProvenanceLedger(); @@ -69,7 +69,7 @@ public sealed class IffAndCombatTests : IDisposable TargetDetectionInfoThreshold = .5f }; - private Ship NewShip(ItemManager items, Zone zone, Faction faction = null, bool isPlayerShip = false, bool equipGun = false) + internal Ship NewShip(ItemManager items, Zone zone, Faction faction = null, bool isPlayerShip = false, bool equipGun = false, bool piloted = false) { var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Skiff")); var hull = new EquippableItem { Data = hullRef, Durability = 1, Lot = 1 }; @@ -82,14 +82,19 @@ public sealed class IffAndCombatTests : IDisposable Assert.True(ship.TryEquip(gun, HardpointCell)); } - zone.Entities.Add(ship); - ship.Activate(); + // A piloted ship joins as every piloted admission does (Zone.Admit), and so gets its agent and its flight. + if (piloted) zone.Admit(ship, true); + else + { + zone.Entities.Add(ship); + ship.Activate(); + } return ship; } // Marks entity `observer` as having detected `target`, the same way Sensor ramps EntityInfoGathered // past TargetDetectionInfoThreshold in the real game. - private static void Detect(Entity observer, Entity target) => observer.EntityInfoGathered[target] = 1; + internal static void Detect(Entity observer, Entity target) => observer.EntityInfoGathered[target] = 1; [Fact] public void OverrideDecidesOutrightOverDerivedRule() diff --git a/tests/Aetheria.Shared.Tests/MineTests.cs b/tests/Aetheria.Shared.Tests/MineTests.cs index ba3c0f0d5..cd2a4c733 100644 --- a/tests/Aetheria.Shared.Tests/MineTests.cs +++ b/tests/Aetheria.Shared.Tests/MineTests.cs @@ -623,9 +623,9 @@ public sealed class MineTests : IDisposable } lab.Zone.Update(1f); - Assert.False(FireControl.AgentFires(lab.Weapon, lab.Layer, null)); - Assert.False(FireControl.AgentFires(lab.Weapon, lab.Layer, far)); - Assert.True(FireControl.AgentFires(lab.Weapon, lab.Layer, near)); + Assert.False(FireControl.AgentFires(lab.Weapon, lab.Layer, null, lab.Layer.ItemManager.GameplaySettings.AgentMinHitProbability)); + Assert.False(FireControl.AgentFires(lab.Weapon, lab.Layer, far, lab.Layer.ItemManager.GameplaySettings.AgentMinHitProbability)); + Assert.True(FireControl.AgentFires(lab.Weapon, lab.Layer, near, lab.Layer.ItemManager.GameplaySettings.AgentMinHitProbability)); Assert.False(FireControl.Refuses(lab.Weapon, lab.Layer)); } @@ -667,7 +667,7 @@ public sealed class MineTests : IDisposable lab.Zone.Update(1f); Assert.False(FireControl.Refuses(lab.Weapon, lab.Layer)); - Assert.True(FireControl.AgentFires(lab.Weapon, lab.Layer, near)); + Assert.True(FireControl.AgentFires(lab.Weapon, lab.Layer, near, lab.Layer.ItemManager.GameplaySettings.AgentMinHitProbability)); var mine = LayOne(lab); Assert.Equal(150f, mine.BlastRadius); } diff --git a/tools/AetherDb/DoctrineCatalog.cs b/tools/AetherDb/DoctrineCatalog.cs new file mode 100644 index 000000000..5b0413d62 --- /dev/null +++ b/tools/AetherDb/DoctrineCatalog.cs @@ -0,0 +1,69 @@ +using System; +using System.IO; +using System.Linq; +using GameCult.Caching; + +// Authors the starting FactionDoctrine of the four factions the demo cast uses (aetheria-release, faction-play-1). +// Tuning is a starting point; the operator adjusts it after play. A faction with no Faction record is named, not +// created: the Pirates' record is owed by follow-up pirates-catalog-record, and re-running apply writes them once it lands. +public static class DoctrineCatalog +{ + private static readonly (string faction, FactionDoctrine doctrine)[] Table = + { + ("Zhestokost", new FactionDoctrine + { + EngageOn = EngageOn.Trespass, Grace = 6f, Hail = "Cut thrust and hold for inspection.", ComplySpeed = 10f, + Combatant = new RoleDoctrine { RangeExponent = .05f, MinHitProbability = .05f } + }), + ("Lucent Media", new FactionDoctrine + { + EngageOn = EngageOn.Detection, Grace = 2f, Hail = "You're live.", + Combatant = new RoleDoctrine { RangeExponent = .25f, MinHitProbability = .3f } + }), + ("Aeronautics Unlimited", new FactionDoctrine + { + EngageOn = EngageOn.Detection, + Combatant = new RoleDoctrine { RangeExponent = .25f, MinHitProbability = .05f } + }), + ("Pirates", new FactionDoctrine + { + EngageOn = EngageOn.Detection, Grace = 8f, Hail = "Drop your cargo and you can go.", + Combatant = new RoleDoctrine { RangeExponent = .5f, MinHitProbability = .15f } + }), + }; + + // Dry run unless apply, as the other catalog commands. The cache must be writable for apply. + public static int Run(CultCache cache, bool apply, TextWriter output) + { + var factions = cache.GetAll().ToArray(); + var changed = new System.Collections.Generic.List(); + foreach (var (name, doctrine) in Table) + { + var faction = factions.FirstOrDefault(f => string.Equals(f.Name, name, StringComparison.OrdinalIgnoreCase)); + if (faction == null) + { + output.WriteLine($"{name}: no Faction record, doctrine not written"); + continue; + } + + output.WriteLine($"{name}: engage on {doctrine.EngageOn}, grace {doctrine.Grace:0.##}s, hail \"{doctrine.Hail}\", " + + $"comply {doctrine.ComplySpeed:0.##}, range exponent {doctrine.Combatant.RangeExponent:0.##}, " + + $"min hit {doctrine.Combatant.MinHitProbability:0.##}"); + faction.Doctrine = doctrine; + changed.Add(faction); + } + + if (!apply) + { + output.WriteLine("\nDry run. Pass \"apply\" to author them."); + return 0; + } + + cache.Commit(batch => + { + foreach (var faction in changed) batch.Upsert(typeof(Faction), faction, cache.RefOf(faction).Key); + }); + output.WriteLine($"\nAuthored {changed.Count} doctrines in Aetheria.cc"); + return 0; + } +} diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index c65f4c3f2..d0ed0fbfc 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -38,8 +38,9 @@ public static class Program 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")); + case "doctrine-catalog": return DoctrineCatalog.Run(AetherDb.Open(catalogWritable: args.Contains("apply")).Cache, args.Contains("apply"), Console.Out); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], doctrine-catalog [apply]"); return 1; } } @@ -659,7 +660,7 @@ public static class Program { var db = AetherDb.Open(); var products = db.Cache.GetAll().ToArray(); - Console.WriteLine($"{"faction",-26} {"short",-13} {"geonames",-22} {"boss hull",-14} {"influence",-9} products"); + Console.WriteLine($"{"faction",-26} {"short",-13} {"geonames",-22} {"boss hull",-14} {"influence",-9} {"doctrine",-10} products"); var broken = 0; foreach (var faction in db.Cache.GetAll().OrderBy(f => f.Name)) { @@ -669,7 +670,7 @@ public static class Program ? "none" : db.Cache.Get(faction.BossHull)?.Name ?? "DANGLING"; if (geonames == "UNSET" || boss == "DANGLING") broken++; - Console.WriteLine($"{faction.Name,-26} {faction.ShortName,-13} {geonames,-22} {boss,-14} {faction.InfluenceDistance,-9} " + + Console.WriteLine($"{faction.Name,-26} {faction.ShortName,-13} {geonames,-22} {boss,-14} {faction.InfluenceDistance,-9} {(faction.Doctrine == null ? "none" : faction.Doctrine.EngageOn.ToString()),-10} " + products.Count(p => p.Manufacturer.Key.Equals(key))); } Console.WriteLine($"\n{broken} factions carry a link that would break generation"); From 90462179b85dad4113477787e14752ab80f2407f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 14:21:04 +0200 Subject: [PATCH 002/769] SteeringTests: Agent takes its RoleDoctrine Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/SteeringTests.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/SteeringTests.cs b/tests/Aetheria.Shared.Tests/SteeringTests.cs index b87e91f71..aeb48c573 100644 --- a/tests/Aetheria.Shared.Tests/SteeringTests.cs +++ b/tests/Aetheria.Shared.Tests/SteeringTests.cs @@ -161,7 +161,7 @@ public sealed class SteeringTests { using var cache = RestoredHullsTests.OpenCatalog(); var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull); - var agent = new Agent(ship); + var agent = new Agent(ship, FactionDoctrine.Default(ship.ItemManager.GameplaySettings).Combatant); var aim = float3(0, 0, 1); ship.Aim = aim; @@ -182,7 +182,7 @@ public sealed class SteeringTests { using var cache = RestoredHullsTests.OpenCatalog(); var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull); - var agent = new Agent(ship); + var agent = new Agent(ship, FactionDoctrine.Default(ship.ItemManager.GameplaySettings).Combatant); Assert.Equal(float2(0, 1), ship.Direction); // starboard is +x ship.Turn = .37f; @@ -210,7 +210,7 @@ public sealed class SteeringTests { using var cache = RestoredHullsTests.OpenCatalog(); var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull); - var agent = new Agent(ship); + var agent = new Agent(ship, FactionDoctrine.Default(ship.ItemManager.GameplaySettings).Combatant); ship.Velocity = float2(100, 0); ship.Turn = .37f; @@ -332,7 +332,7 @@ public sealed class SteeringTests { using var cache = RestoredHullsTests.OpenCatalog(); var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull); - var agent = new Agent(ship); + var agent = new Agent(ship, FactionDoctrine.Default(ship.ItemManager.GameplaySettings).Combatant); var start = ship.Direction; agent.Transition(new SpinState(agent)); @@ -375,7 +375,7 @@ public sealed class SteeringTests { using var cache = RestoredHullsTests.OpenCatalog(); var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull); - var agent = new Agent(ship); + var agent = new Agent(ship, FactionDoctrine.Default(ship.ItemManager.GameplaySettings).Combatant); var state = new MoveToPointState(agent); var dir = Heading(ship, degrees); state.Point = dir * 500f; From 9978d2876380526befc15bff0967c8fc42cb0d67 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 14:29:47 +0200 Subject: [PATCH 003/769] PatrolOrbitsState: arrival advances the circuit only while the pilot is still on its task Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Agents/Tasks/PatrolOrbits.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/Agents/Tasks/PatrolOrbits.cs b/Assets/Scripts/ServerShared/Agents/Tasks/PatrolOrbits.cs index f2a26771d..d6e0f009e 100644 --- a/Assets/Scripts/ServerShared/Agents/Tasks/PatrolOrbits.cs +++ b/Assets/Scripts/ServerShared/Agents/Tasks/PatrolOrbits.cs @@ -27,6 +27,7 @@ public class PatrolOrbitsState : BaseState Transitions.Add(new StateTransition(patrolMoveState, () => true, () => patrolMoveState.Orbit = CurrentTarget)); - patrolMoveState.Transitions.Add(new StateTransition(this, () => patrolMoveState.Distance < 10, NextTarget)); + // Arrival advances the circuit only while the pilot is still on this task: a task change leaves the patrol first. + patrolMoveState.Transitions.Add(new StateTransition(this, () => patrolMoveState.Distance < 10 && agent.Task == Task, NextTarget)); } } \ No newline at end of file From 080dbed07d1f8c7804eb32dedd1554ee8e7712f9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 14:32:15 +0200 Subject: [PATCH 004/769] Catalog: doctrine-catalog apply (Zhestokost, Lucent Media, Aeronautics Unlimited) Ran on Yggdrasil against the base catalog; the Pirates have no Faction record (follow-up pirates-catalog-record), so their doctrine is not written. Miss Terri's untouched. Co-Authored-By: Claude Sonnet 5.5 --- GameData/Aetheria.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index 96135d923..927989f13 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:63250cf21ae9abeec657f0603336bb11b4d50b66560903ef45a8cceb01a7ae25 -size 12703489 +oid sha256:608e1d836b2e8cdd056e0f8646556d813b0862e264a5e58861884dcc508d7be3 +size 12703822 From c8c544f48977d30940bf24ce91cd752e114a7f6a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 14:35:00 +0200 Subject: [PATCH 005/769] PatrolOrbitsState: compare the task by reference Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Agents/Tasks/PatrolOrbits.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/Agents/Tasks/PatrolOrbits.cs b/Assets/Scripts/ServerShared/Agents/Tasks/PatrolOrbits.cs index d6e0f009e..4fc844985 100644 --- a/Assets/Scripts/ServerShared/Agents/Tasks/PatrolOrbits.cs +++ b/Assets/Scripts/ServerShared/Agents/Tasks/PatrolOrbits.cs @@ -28,6 +28,6 @@ public class PatrolOrbitsState : BaseState () => true, () => patrolMoveState.Orbit = CurrentTarget)); // Arrival advances the circuit only while the pilot is still on this task: a task change leaves the patrol first. - patrolMoveState.Transitions.Add(new StateTransition(this, () => patrolMoveState.Distance < 10 && agent.Task == Task, NextTarget)); + patrolMoveState.Transitions.Add(new StateTransition(this, () => patrolMoveState.Distance < 10 && ReferenceEquals(agent.Task, Task), NextTarget)); } } \ No newline at end of file From c3c6d12db886b8f77cf0153110cb88d94537d9c7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 16:12:53 +0200 Subject: [PATCH 006/769] FlightTests: boundary, selection, trespass, standoff and task-change pins Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/FlightTests.cs | 236 +++++++++++++++++++++ 1 file changed, 236 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs index c049c9d43..bf48be987 100644 --- a/tests/Aetheria.Shared.Tests/FlightTests.cs +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -6,6 +6,7 @@ using CultMath; using GameCult.Caching; using UniRx; using Xunit; +using static CultMath.math; // The flight (Agents/Flight.cs) is the only writer of its members' Target and Task. These tests drive it on the // IffAndCombatTests world. Perception is the sensors' output (VisibleEnemies, EntityInfoGathered), which @@ -257,6 +258,241 @@ public sealed class FlightTests : IDisposable Assert.DoesNotContain(_zone.Agents, a => a.Ship == player); } + private static void Forbid(Ship track) => track.PresencePermitted = Observable.Return(false).ToReadOnlyReactiveProperty(); + + [Fact] + public void TrespassEngagesOnlyWhatTheZoneDoesNotPermit() + { + var member = Pilot(WithDoctrine(EngageOn.Trespass)); + var welcome = Track(80); + var trespasser = Track(120); + See(member, welcome); + See(member, trespasser); + welcome.PresencePermitted = Observable.Return(true).ToReadOnlyReactiveProperty(); + Forbid(trespasser); + + Tick(.1f); + + Assert.Same(trespasser, member.Target.Value.Entity); + } + + [Fact] + public void ADetectionDoctrineEngagesWhateverIsVisibleWithoutAGrudge() + { + var member = Pilot(WithDoctrine(EngageOn.Detection)); + var track = Track(); + See(member, track); + + Tick(.1f); + + Assert.Same(track, member.Target.Value.Entity); + Assert.Null(member.IffOverride(track)); + } + + [Fact] + public void ADoctrineWithoutACombatantFightsWithTheDefaultRole() + { + var faction = new Faction { Name = "Sparse", Doctrine = new FactionDoctrine { EngageOn = EngageOn.Detection } }; + Pilot(faction); + + var combatant = _zone.Flights.Single().Combatant; + + Assert.Equal(_items.GameplaySettings.AgentRangeExponent, combatant.RangeExponent); + Assert.Equal(_items.GameplaySettings.AgentMinHitProbability, combatant.MinHitProbability); + } + + [Fact] + public void AnyMembersGrudgeEngagesTheWholeFlight() + { + var faction = WithDoctrine(EngageOn.Provoked); + var angry = Pilot(faction); + var calm = Pilot(faction, 10); + var track = Track(); + See(angry, track); + See(calm, track); + angry.SetIff(track, true); + + Tick(.1f); + + Assert.Same(track, angry.Target.Value.Entity); + Assert.Same(track, calm.Target.Value.Entity); + } + + [Fact] + public void TheGraceEndsExactlyWhenItIsUp() + { + var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold.")); + See(member, Track()); + + Tick(.5f); // hailing starts at 0.5 s + Tick(4.5f); + Assert.True(member.Target.Value.IsNone, "4.5 s into a 5 s grace"); + Tick(.5f); + Assert.False(member.Target.Value.IsNone, "5.0 s in"); + } + + [Fact] + public void NothingComplyingMeansTheGraceEndsInEngagement() + { + var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 1, hail: "Hold.", comply: 0)); + See(member, Track()); + + Tick(.5f); + Tick(1f); + + Assert.False(member.Target.Value.IsNone); + } + + [Fact] + public void ATrackExactlyAtComplySpeedDoesNotComply() + { + var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 1, hail: "Hold.", comply: 10)); + var track = Track(); + track.Velocity = new float2(10, 0); + See(member, track); + + Tick(.5f); + Tick(1f); + + Assert.Same(track, member.Target.Value.Entity); + } + + [Fact] + public void ACompliantTrackAtExactlyComplySpeedIsEngaged() + { + var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 1, hail: "Hold.", comply: 10)); + var track = Track(); + See(member, track); + + Tick(.5f); + Tick(1f); + Assert.True(member.Target.Value.IsNone, "complied while still"); + + track.Velocity = new float2(10, 0); + Tick(.1f); + Assert.Same(track, member.Target.Value.Entity); + } + + [Fact] + public void AMemberKeepsItsTargetWhileThatTrackStaysEngaged() + { + var member = Pilot(new Faction { Name = "Plain" }); + var first = Track(50); + var second = Track(200); + See(member, first); + See(member, second); + + Tick(.1f); + Assert.Same(first, member.Target.Value.Entity); // the nearer + + first.Position = new float3(500, 0, 0); + second.Position = new float3(10, 0, 0); + Tick(.1f); + Assert.Same(first, member.Target.Value.Entity); // kept, though the other is nearer now + } + + [Fact] + public void HoldersCloseOnTheNearestHeldTrackAndKeepItWhileItIsHeld() + { + var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold.")); + var near = Track(50); + var far = Track(300); + See(member, near); + See(member, far); + + Tick(.1f); + Assert.Same(near, ((FollowTask) _zone.Agents.Single().Task).Anchor); + + near.Position = new float3(900, 0, 0); + far.Position = new float3(10, 0, 0); + Tick(.1f); + Assert.Same(near, ((FollowTask) _zone.Agents.Single().Task).Anchor); + } + + [Fact] + public void ATrackThatLeavesForgetsItsPhaseAndTheMemberResumesItsPatrol() + { + var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold.")); + var agent = _zone.Agents.Single(); + var patrol = agent.Task; + var track = Track(); + See(member, track); + + Tick(.1f); + Assert.IsType(agent.Task); + + member.VisibleEnemies.Remove(track); + Tick(.1f); + Assert.Same(patrol, agent.Task); + Assert.Equal(Flight.Phase.None, _zone.Flights.Single().PhaseOf(track)); + + member.Messages.Clear(); + See(member, track); + Tick(.1f); + Assert.True(member.Messages.ContainsKey("Hold."), "a returning track is hailed again"); + } + + [Fact] + public void AnAuthoredStandoffIsTheHoldDistance() + { + var faction = WithDoctrine(EngageOn.Detection, grace: 5); + faction.Doctrine.Combatant.HoldStandoff = 40; + var member = Pilot(faction); + See(member, Track()); + + Tick(.1f); + + Assert.Equal(40f, ((FollowTask) _zone.Agents.Single().Task).Standoff); + } + + [Fact] + public void FollowTurnsTowardTheAnchorWhenFarAndAwayFromItWhenNear() + { + var anchor = Track(); + + float Turn(float anchorX) + { + var ship = Pilot(null); + anchor.Position = new float3(anchorX, 0, 0); + var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) + { Task = new FollowTask { Anchor = anchor, Standoff = 100 } }; + minion.Update(.1f); + minion.Update(.1f); + return ship.Turn; + } + + var far = Turn(300); + var near = Turn(20); + + Assert.NotEqual(0f, far); + Assert.NotEqual(0f, near); + Assert.Equal(sign(far), -sign(near)); + } + + [Fact] + public void ATaskChangeBackToPatrolLeavesTheFollowState() + { + var ship = Pilot(null); + var anchor = Track(); + var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) + { Task = new FollowTask { Anchor = anchor, Standoff = 50 } }; + minion.Update(.1f); + minion.Update(.1f); + Assert.IsType(CurrentState(minion)); + + minion.Task = new PatrolOrbitsTask { Circuit = new CultRecordKey[1] }; + minion.Update(.1f); + Assert.IsType(CurrentState(minion)); + + minion.Task = new FollowTask { Anchor = anchor, Standoff = 50 }; + minion.Update(.1f); + minion.Update(.1f); + minion.Task = null; + minion.Update(.1f); + minion.Update(.1f); + Assert.IsNotType(CurrentState(minion)); + } + private static string TempCatalog() => Path.Combine(Path.GetTempPath(), "aetheria-doctrine-" + Guid.NewGuid().ToString("N") + ".cc"); [Fact] From 290b34331a20bf5a96ba22c3aa5faf57ded8e621 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 16:20:33 +0200 Subject: [PATCH 007/769] Minion: a task change leaves its own state only; the walk no longer crosses into the other task's states Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Agents/Minion.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Assets/Scripts/ServerShared/Agents/Minion.cs b/Assets/Scripts/ServerShared/Agents/Minion.cs index 80ee3c34f..41c0e3fe1 100644 --- a/Assets/Scripts/ServerShared/Agents/Minion.cs +++ b/Assets/Scripts/ServerShared/Agents/Minion.cs @@ -14,11 +14,11 @@ public class Minion : Agent _rootState.AddTransition(followState, () => Task is FollowTask); // A Task change switches the substate in one update, from wherever the pilot is in the old task. includeChildren - // reaches the sub-states (MoveToOrbit) that Update actually sits in. + // reaches the patrol sub-states (MoveToOrbit) that Update actually sits in; the walk never enters the other task's state. patrolState.AddTransition(followState, () => Task is FollowTask, null, true, _rootState); - followState.AddTransition(patrolState, () => Task is PatrolOrbitsTask, Patrol, true, _rootState); - patrolState.AddTransition(_rootState, () => !(Task is PatrolOrbitsTask), null, true, _rootState); - followState.AddTransition(_rootState, () => !(Task is FollowTask), null, true, _rootState); + patrolState.AddTransition(_rootState, () => !(Task is PatrolOrbitsTask), null, true, _rootState, followState); + followState.AddTransition(patrolState, () => Task is PatrolOrbitsTask, Patrol); + followState.AddTransition(_rootState, () => !(Task is FollowTask)); var combatState = new CombatState(this); _rootState.AddTransition(combatState, From 7217112873554d4ecd88d4a09c9ef634cfce15cf Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 16:21:37 +0200 Subject: [PATCH 008/769] FlightTests: a patrolling pilot never falls back to the root state Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/FlightTests.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs index bf48be987..d48e9466c 100644 --- a/tests/Aetheria.Shared.Tests/FlightTests.cs +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -235,6 +235,20 @@ public sealed class FlightTests : IDisposable Assert.IsType(CurrentState(minion)); } + [Fact] + public void APatrollingPilotStaysOnPatrol() + { + var ship = Pilot(null); + var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) + { Task = new PatrolOrbitsTask { Circuit = new CultRecordKey[1] } }; + + for (var i = 0; i < 30; i++) + { + minion.Update(.1f); + if (i > 0) Assert.IsNotType(CurrentState(minion)); + } + } + private static BaseState CurrentState(Agent agent) => (BaseState) typeof(Agent).GetField("_currentState", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(agent); From e95116b670607043b4a23bdd84e89906b3bf0b28 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 16:30:34 +0200 Subject: [PATCH 009/769] Tests: removal order, per-faction flights, zone tick, exponent margin Co-Authored-By: Claude Sonnet 5.5 --- .../FireControlPerWeaponTests.cs | 2 +- tests/Aetheria.Shared.Tests/FlightTests.cs | 28 ++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 49a319e56..4fa17efd2 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -656,7 +656,7 @@ public sealed class FireControlPerWeaponTests : IDisposable float Optimum(float exponent) => new CombatState(new Minion(r.Shooter, new RoleDoctrine { RangeExponent = exponent, MinHitProbability = .2f })).OptimumRange; - Assert.True(Optimum(0f) < Optimum(1f)); + Assert.True(Optimum(1f) - Optimum(0f) > 100f); } // A round refused on a Range that fell between the trigger and the round is still free: the burst is paid diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs index d48e9466c..0465acd97 100644 --- a/tests/Aetheria.Shared.Tests/FlightTests.cs +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -265,10 +265,13 @@ public sealed class FlightTests : IDisposable dies.Hull.Durability = 0; dies.HullDamage.OnNext(1f); - _zone.Entities.Remove(leaves); + Assert.Equal(new[] { leaves, stays }, _zone.Agents.Select(a => a.Ship)); + Assert.Equal(2, flight.Count); + _zone.Entities.Remove(leaves); Assert.Equal(new[] { stays }, _zone.Agents.Select(a => a.Ship)); Assert.Equal(1, flight.Count); + Assert.True(flight.Contains(_zone.Agents.Single())); Assert.DoesNotContain(_zone.Agents, a => a.Ship == player); } @@ -303,6 +306,29 @@ public sealed class FlightTests : IDisposable Assert.Null(member.IffOverride(track)); } + [Fact] + public void EachFactionFliesAFlightOfItsOwnAndUnaffiliatedShipsShareOne() + { + Pilot(new Faction { Name = "One" }); + Pilot(new Faction { Name = "Two" }); + Pilot(null); + Pilot(null); + + Assert.Equal(new[] { 1, 1, 2 }, _zone.Flights.Select(f => f.Count).OrderBy(c => c)); + } + + [Fact] + public void TheZoneTickRunsTheFlightsBeforeTheAgents() + { + var member = Pilot(new Faction { Name = "Plain" }); + var track = Track(); + See(member, track); + + _zone.Update(.1f); + + Assert.Same(track, member.Target.Value.Entity); + } + [Fact] public void ADoctrineWithoutACombatantFightsWithTheDefaultRole() { From f91f658ad21a871ff0f1b4276b6d5a6597fa8fbd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 18:13:48 +0200 Subject: [PATCH 010/769] Flight: a member that cannot see the engaged track steers on its bearing The member kept Target to its own sensors but fell back to patrol while a flightmate fought; it now follows the engaged track as it followed the hailed one (ruling shared-track-bearing-only: a bearing, never targeting data). Builds: not yet verified at this commit. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Agents/Flight.cs | 4 +++- tests/Aetheria.Shared.Tests/FlightTests.cs | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/Agents/Flight.cs b/Assets/Scripts/ServerShared/Agents/Flight.cs index 1e6cccce1..965aa0cd9 100644 --- a/Assets/Scripts/ServerShared/Agents/Flight.cs +++ b/Assets/Scripts/ServerShared/Agents/Flight.cs @@ -150,8 +150,10 @@ public class Flight return; } + // No engaged track in this member's own sensors: it steers on the flight's picture, a bearing and never targeting + // data (ruling shared-track-bearing-only). That is the hailed and the complied, and an engaged track only a flightmate sees. Aim(ship, null); - var held = picture.Where(e => _tracks[e].Phase == Phase.Hailing || _tracks[e].Phase == Phase.Complied).ToList(); + var held = picture.Where(e => _tracks[e].Phase != Phase.None).ToList(); if (held.Count > 0) { var anchor = member.Agent.Task is FollowTask follow && held.Contains(follow.Anchor) diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs index 0465acd97..447023db4 100644 --- a/tests/Aetheria.Shared.Tests/FlightTests.cs +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -533,6 +533,22 @@ public sealed class FlightTests : IDisposable Assert.IsNotType(CurrentState(minion)); } + [Fact] + public void AMemberThatCannotSeeTheEngagedTrackClosesOnItsBearing() + { + var faction = WithDoctrine(EngageOn.Detection); + var sighted = Pilot(faction); + var blind = Pilot(faction, 10); + var track = Track(); + See(sighted, track); + Tick(.1f); + + Assert.Same(track, sighted.Target.Value.Entity); + Assert.True(blind.Target.Value.IsNone); + var task = Assert.IsType(_zone.Agents.Single(a => a.Ship == blind).Task); + Assert.Same(track, task.Anchor); + } + private static string TempCatalog() => Path.Combine(Path.GetTempPath(), "aetheria-doctrine-" + Guid.NewGuid().ToString("N") + ".cc"); [Fact] From 1fea9cd2e113a9dbb70881ced305fc7bff16e3a4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 18:13:50 +0200 Subject: [PATCH 011/769] FlightTests: pin the executor's combat and root transitions Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/FlightTests.cs | 52 ++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs index 447023db4..3920ac54d 100644 --- a/tests/Aetheria.Shared.Tests/FlightTests.cs +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -549,6 +549,58 @@ public sealed class FlightTests : IDisposable Assert.Same(track, task.Anchor); } + private Minion PatrollingMinion(Ship ship) + { + var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) + { Task = new PatrolOrbitsTask { Circuit = new CultRecordKey[1] } }; + for (var i = 0; i < 4; i++) minion.Update(.1f); + Assert.IsType(CurrentState(minion)); + return minion; + } + + [Fact] + public void ATargetPullsAPatrollingPilotIntoCombatAndLosingItReturnsToPatrol() + { + var ship = Pilot(null); + var minion = PatrollingMinion(ship); + + ship.SetTarget(new TargetRef(Track())); + minion.Update(.1f); + Assert.IsType(CurrentState(minion)); + + ship.SetTarget(TargetRef.None); + minion.Update(.1f); + Assert.IsNotType(CurrentState(minion)); + minion.Update(.1f); + Assert.IsType(CurrentState(minion)); + } + + [Fact] + public void CombatHoldsWhileTheTargetStands() + { + var ship = Pilot(null); + var minion = PatrollingMinion(ship); + ship.SetTarget(new TargetRef(Track())); + + for (var i = 0; i < 5; i++) + { + minion.Update(.1f); + Assert.IsType(CurrentState(minion)); + } + } + + [Fact] + public void ATaskThatIsNeitherPatrolNorFollowSendsAPatrollingPilotToTheRoot() + { + var ship = Pilot(null); + var minion = PatrollingMinion(ship); + + minion.Task = null; + minion.Update(.1f); + + Assert.IsType(CurrentState(minion)); + } + private static string TempCatalog() => Path.Combine(Path.GetTempPath(), "aetheria-doctrine-" + Guid.NewGuid().ToString("N") + ".cc"); [Fact] From c1b83e84e555ba47ff502d10a6a6941803ef7305 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 18:31:32 +0200 Subject: [PATCH 012/769] FlightTests: the combat transition tests give the pilot a track it sees Entity clears a target its pilot does not see; the earlier commit set one without perception, so the pilot never entered combat. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/FlightTests.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs index 3920ac54d..ec01153ea 100644 --- a/tests/Aetheria.Shared.Tests/FlightTests.cs +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -564,7 +564,9 @@ public sealed class FlightTests : IDisposable var ship = Pilot(null); var minion = PatrollingMinion(ship); - ship.SetTarget(new TargetRef(Track())); + var track = Track(); + See(ship, track); + ship.SetTarget(new TargetRef(track)); minion.Update(.1f); Assert.IsType(CurrentState(minion)); @@ -580,7 +582,9 @@ public sealed class FlightTests : IDisposable { var ship = Pilot(null); var minion = PatrollingMinion(ship); - ship.SetTarget(new TargetRef(Track())); + var track = Track(); + See(ship, track); + ship.SetTarget(new TargetRef(track)); for (var i = 0; i < 5; i++) { From a2ad79a68e81912122b93491ed88e5545802dac9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 18:33:31 +0200 Subject: [PATCH 013/769] FlightTests: combat transitions are looked for across the patrol sub-states' own hand-overs Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/FlightTests.cs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs index ec01153ea..40fa9fe3f 100644 --- a/tests/Aetheria.Shared.Tests/FlightTests.cs +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -558,23 +558,28 @@ public sealed class FlightTests : IDisposable return minion; } + // Patrol's own sub-states may hand over to one another first, so a transition out of patrol is looked for within a + // few updates; none of them ever reaches combat unless the interrupt covers the patrol sub-states. + private static void UpdateUntil(Minion minion, Func done) + { + for (var i = 0; i < 3 && !done(); i++) minion.Update(.1f); + Assert.True(done(), "pilot is in " + CurrentState(minion).GetType().Name); + } + [Fact] public void ATargetPullsAPatrollingPilotIntoCombatAndLosingItReturnsToPatrol() { var ship = Pilot(null); var minion = PatrollingMinion(ship); - var track = Track(); See(ship, track); + ship.SetTarget(new TargetRef(track)); - minion.Update(.1f); - Assert.IsType(CurrentState(minion)); + UpdateUntil(minion, () => CurrentState(minion) is CombatState); ship.SetTarget(TargetRef.None); - minion.Update(.1f); - Assert.IsNotType(CurrentState(minion)); - minion.Update(.1f); - Assert.IsType(CurrentState(minion)); + UpdateUntil(minion, () => !(CurrentState(minion) is CombatState)); + UpdateUntil(minion, () => CurrentState(minion) is PatrolOrbitsState || CurrentState(minion) is MoveToOrbitState); } [Fact] @@ -585,6 +590,7 @@ public sealed class FlightTests : IDisposable var track = Track(); See(ship, track); ship.SetTarget(new TargetRef(track)); + UpdateUntil(minion, () => CurrentState(minion) is CombatState); for (var i = 0; i < 5; i++) { From b3d1e92904ecf13a0cf340e8afadde20dba4e2e1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 18:52:43 +0200 Subject: [PATCH 014/769] FlightTests: the patrolling fixture starts far from its circuit point At distance 0 MoveToOrbit hands back to patrol every tick and the patrol state's unconditional transition wins over the combat interrupt, so no combat transition was ever reachable in the fixture. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/FlightTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs index 40fa9fe3f..db9d9dc15 100644 --- a/tests/Aetheria.Shared.Tests/FlightTests.cs +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -551,6 +551,8 @@ public sealed class FlightTests : IDisposable private Minion PatrollingMinion(Ship ship) { + // Far from the (unresolved) circuit point, so MoveToOrbit does not hand back to patrol on arrival every tick. + ship.Position = new float3(500, 0, 0); var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) { Task = new PatrolOrbitsTask { Circuit = new CultRecordKey[1] } }; for (var i = 0; i < 4; i++) minion.Update(.1f); From 52eecd6da1497a172339a22532d81d423e8446d2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 01:56:32 +0200 Subject: [PATCH 015/769] Research: closed-form, runtime fauna placement prior art, for fauna target r3 Co-Authored-By: Claude Opus 5.5 --- .../2026-10-14-fauna-placement-closed-form.md | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/research/2026-10-14-fauna-placement-closed-form.md diff --git a/docs/research/2026-10-14-fauna-placement-closed-form.md b/docs/research/2026-10-14-fauna-placement-closed-form.md new file mode 100644 index 000000000..d11922b53 --- /dev/null +++ b/docs/research/2026-10-14-fauna-placement-closed-form.md @@ -0,0 +1,56 @@ +# Closed-form, runtime-evaluated fauna placement: prior-art facts +Agent eyes-fauna-placement-closed-form, session self-2026-10-14-ag, campaign aetheria-fauna, 2026-10-14. No recommendations. + +Tags: [V] verified by reading the source text this pass (full text or fetched page). [S] a search-result summary or snippet, page not read in full. [M] from memory or derived by me, unverified. "PW" = pointwise (needs only the field and its spatial derivatives at the point, at one instant). "PI" = path-integrated (needs trajectories, history, or a spatial integral). +Scratch copies of fetched PDFs: C:\Users\Meta\.claude\projects\F--Projects-CultLib\5ea0b87f-66d0-4b24-8d36-45015e4cedc1\tool-results\ (les.txt, drift.txt, g2\*.txt). +Already covered by the earlier docs (not repeated): Physarum/MCPM, EcoBrush, D8, Maxent basics, float determinism, boids/LOD, radar/DSL clutter. + +## 1. Patchiness from flow: what local quantity predicts aggregation + +1.1 Okubo-Weiss. W = s_n^2 + s_s^2 - omega^2 (normal strain, shear strain, relative vorticity), for horizontally non-divergent 2D flow. W>0 deformation (saddle), W<0 rotation (vortex), W=0 needs quadratic terms. Eigenvalues of the velocity-gradient matrix are (gamma +- sqrt(W))/2 with gamma the divergence; complex eigenvalues give spirals that converge if gamma<0. Sign convention varies between sources (py-eddy-tracker docs contradict their own code). PW: needs only the 2x2 velocity gradient at the point. It classifies local flow topology; it does not itself give a density. [V] https://en.wikipedia.org/wiki/Okubo%E2%80%93Weiss_parameter (page is flagged there for expert attention). Originals: Okubo 1970 Deep-Sea Res. 17(3):445-454; Weiss 1991 Physica D 48:273-294 [V as citations on that page]. 3D analogues cited there: Q criterion (Hunt, Wray, Moin 1988), Chong-Perry-Cantwell 1990, Jeong-Hussain 1995 (lambda2) [V as citations]. +1.2 Okubo-Weiss to plankton link: no source found that ties W to plankton accumulation directly; W is used to split vortex versus strain regions in an island-wake biology paper (arXiv 0802.3532) and a nutrient-flux study (Sci. Rep. 2024 SI) [S]. Known caveats of W as a vortex detector (Eulerian, frame-dependent, misclassifies in time-dependent flows; Basdevant and Philipovitch 1994) [M]. +1.3 FTLE: FTLE(x) = log(lambda_max(C))/(2T), C = Cauchy-Green tensor of the flow map over [t, t+T]. Needs advecting particles or a perturbation over T. PI. T is a free parameter. [V] https://lagrangian.readthedocs.io/en/latest/contents.html . Backward-time FTLE ridges approximate attracting LCS; tracer-gradient alignment with backward finite-time Lyapunov vectors is shown for freely decaying 2D turbulence and observed in realistic ocean flows, not presented as general [V, same page]. +1.4 FTLE failure modes: ridges capture hyperbolic LCS only under additional conditions; ridges connect high stretching with high shear, so false positives; no reliable detection of elliptic structures. A twelve-method benchmark found false positives and negatives even in mathematically justified methods [V] Hadjighasem et al., "A critical comparison of Lagrangian methods for coherent structure detection", Chaos 27, 053104 (2017), https://arxiv.org/abs/1704.05716 (Sec. VI strengths/weaknesses list; also the FTLE paragraph in Sec. II). Haller 2015, Annu. Rev. Fluid Mech. 47:137-162 (review, not read this pass) [S]. +1.5 FTLE/FSLE and plankton, field evidence: d'Ovidio et al. 2004 GRL (Mediterranean mixing structures, FSLE); Lehahn, d'Ovidio, Levy, Heifetz 2007 (stirring of the NE Atlantic spring bloom); a Palmer Deep canyon study reporting phytoplankton patches associated with FTLE ridges, strongest in stratified conditions [S, summaries only]. d'Ovidio et al. 2010 PNAS "Fluid dynamical niches of phytoplankton types" [M]. All need a velocity time series and advection: PI. +1.6 Infinitesimal-time limit (my derivation, [M]): as T goes to 0 the FTLE tends to the largest eigenvalue of the symmetric strain tensor S = (A + A^T)/2 at the point (A = velocity gradient), an instantaneous PW quantity that carries none of the history that builds ridges. The instantaneous growth rate of a passive-scalar gradient is d(grad c)/dt = -A^T grad c, PW given the scalar's gradient. +1.7 Langmuir circulation: counter-rotating wind-aligned cells from the interaction of Stokes drift and vertical vorticity (Craik-Leibovich 1976 equations); surface convergence lines make windrows of buoyant material, divergence zones between them stay clear; spacing from under 2 m to over 1 km, streaks up to 15 km downwind [S] https://en.wikipedia.org/wiki/Langmuir_circulation plus sea-ice and LES papers via search. The operative local quantity is surface convergence -div_h(u); the cell spacing is set by mixed-layer physics, not a local gradient. If the roll field is given analytically, -div_h(u) is PW [M]. +1.8 Buoyant-tracer accumulation versus convergence (LES): tracer accumulates in regions of horizontal convergence (downwelling at the surface); less buoyant tracer is submerged more easily and resurfaces in upwelling zones, giving a more uniform distribution; the most buoyant tracer stays near the strongest convergence and reached concentrations up to 50 times those of the least buoyant in the same flow; not every downwelling region fills, convective vortices trap particles in a subset of nodes; clustering is quantified with the Gini coefficient and set by the friction-to-convective velocity ratio and the buoyancy [V] Dingwall, Chor, Taylor, J. Fluid Mech. 954, A27 (2023), doi 10.1017/jfm.2022.969 (text read: abstract, Sec. 1, discussion of figs 3-4). The stated control is particle slip velocity versus peak vertical velocity (about 0.02 m/s there). A simulation result, not a closed form. +1.9 Flotsam on fronts: D'Asaro et al. 2018 PNAS 115, "Ocean convergence and the dispersion of flotsam", cited by that LES paper for accumulation in narrow 10-100 m density fronts [V as citation]; the paper itself not read. + +## 2. Divergence as density proxy: when closed forms exist + +2.1 Continuity: d(rho)/dt + div(rho u) = 0. Along a fluid trajectory D rho/Dt = -rho div u, so rho(t) = rho0 exp(-integral of div u dt) = rho0 / det(Jacobian of the flow map). This is PI: it needs the trajectory or the time-integrated divergence along it; no pointwise form [M, standard]. Balkovsky-Falkovich-Fouxon treat the same object as the "volume compression factor along the Lagrangian trajectory" in a compressible flow (arXiv chao-dyn/9912027, Phys. Rev. Lett. 86:2790, 2001, Sec. 2, around Eq. 2.11) [V as to that phrase; equation not worked through]. +2.2 Steady Eulerian density: d/dt=0 gives div(rho u) = 0. In 1D, or along a channel of fixed cross-section, rho = C/u(x) pointwise (flux conservation), C fixed by total mass. In 2D/3D with div u != 0 there is no pointwise solution in general: integrate along streamlines or solve the PDE; with sinks, the steady state concentrates on the sinks [M, standard continuity reasoning; not sourced this pass]. +2.3 -div u is the instantaneous compression rate, d(ln rho)/dt = -div u at the moving particle: a PW rate, not a density [M, follows from 2.1]. +2.4 Inertial particles in an incompressible flow: to first order in the Stokes time tau, v = u - tau Du/Dt is compressible, div v = -tau tr(A^2) with A = grad u. PW given A (and Du/Dt). Heavy particles leave high-vorticity regions and collect in low-vorticity/high-strain regions; light particles the opposite. Read: in compressible fields preferential concentration occurs where field divergence is negative; in incompressible fields where strain is high and skewness negative; Eq. 25 defines |S|^2 and |omega|^2 and notes that the average of d_a u_b d_b u_a = |S|^2 - |omega|^2/2 vanishes for incompressible fields [V] "Preferential concentration vs. clustering in inertial particle transport by random velocity fields", arXiv 0811.2927, Secs. II, IV (Eqs. 21-27), V. The explicit div v = -tau tr(A^2) is the standard form given in a search summary of Balkovsky et al.; prefactors unchecked [S/M]. Maxey 1987, J. Fluid Mech. 174:441-465, relates the particle-flow compressibility to local straining of the carrier (read through abstract and a secondary account only; the "preferential sweeping" naming is Wang and Maxey 1993) [S]. Validity: weak inertia; the clustering is intermittent with heavy-tailed fluctuations [V] BFF abstract. +2.5 Clustering of inertial particles can also occur in incompressible flow ("clumps and voids") [V] arXiv 0811.2927 Sec. IV. +2.6 Failure modes of the divergence proxy [M]: -div u is a rate, not a density; in a steady flow with sinks the exponential integrates to a singularity at the sink; in an incompressible flow (div u = 0) a uniform passive-tracer number density stays uniform, so no pointwise convergence signal exists and patchiness comes only from the tracer's own initial inhomogeneity or from inertia/buoyancy/swimming. + +## 3. Gravity-slope (topographic) habitat suitability and game density functions + +3.1 Seamount/topography aggregation mechanisms in the literature: enhanced prey flux past topography and interception of vertical migrators (topographic blockage); higher abundance at crests tied to enhanced current amplitudes and convergence zones (Hawaii study) [S] https://deepsea.uac.pt/doi-2901 and linked results. Slope is one covariate among depth, rugosity, aspect, BPI, VRM in Maxent studies; depth was usually the best predictor and slope the weakest in a Western Pacific snapper study [S] agris.fao.org record 671224ca7f591113e2a47f06. Suitability in those is a fitted function; no source found that derives a niche from gradient magnitude alone, and none for shelf-break or upwelling covariates in seamount models [S]. +3.2 Horizon Zero Dawn (Guerrilla, GDC 2017, J. van Muijden): placement is "data driven, deterministic, locally stable"; per-layer DENSITYMAP step evaluates artist logic over mipmapped world-data maps (heights, Erosion_Flow/Wear/Deposition, Water_Flow, Water_Vorticity, Terrain_Cavity, painted Placement_* maps); GENERATE discretises the density map by dithering against a precomputed pattern ("even spread thresholds, maximize 2D distance"), one sample per thread; layered dithering with a two-sided threshold test avoids inter-layer collision readback; about 250 microseconds average GPU load, 500+ asset types, 100,000+ objects; regenerated at run time around the player [V] https://www.guerrilla-games.com/media/News/Files/GDC2017_VanMuijden_GPUBasedProceduralPlacementInHorizonZeroDawn.pdf (slides read, 238 MB PDF). The density is a continuous scalar combined from baked maps; the spatial pattern comes from the fixed threshold pattern. Inputs are baked 2D maps, not analytic pointwise functions. +3.3 Unreal PCG: "Normal To Density" turns the angle between a point's normal and a reference vector into density with Result = Result^(1/Strength) and an Offset; "Density Filter" keeps points whose density is in [LowerBound, UpperBound] (0-1) with optional remap; "Density Noise" modulates density by bounded noise (Density = OriginalDensity op DensityNoise); Surface Sampler can keep zero-density points; seed controls repeatability [S] https://dev.epicgames.com/documentation/unreal-engine/API/Plugins/PCG/UPCGNormalToDensitySettings , .../UPCGDensityFilterSettings , .../UPCGSpatialNoiseSettings . +3.4 Houdini: Scatter SOP "Density Attribute" varies point density smoothly across a primitive from a point or vertex attribute; Relax Iterations gives even spacing (turn off for random look); LOP Scatter Instances has an Up Axis mask with Max Angle [S] https://www.sidefx.com/docs/houdini/nodes/lop/scatterinstances.html and forum posts https://www.sidefx.com/forum/post/178860/ . +3.5 Sources of the "nice patterns": in HZD, painted/generated maps plus the fixed blue-noise-like threshold pattern [V]; in Unreal/Houdini, a density attribute plus relaxation or density noise [S]. No Man's Sky (GDC 2017 talks) not opened this pass [M, unverified]. No source found for a gradient/curvature-to-pattern recipe. + +## 4. Determinism and stability under motion and field change + +4.1 Fixed per-point threshold with field-dependent density: HZD's dither-based discretisation compares the density value to a fixed pattern value; each candidate's threshold is constant, so the set at lower density is a subset of the set at higher density (monotone nesting) and a point appears or disappears only when the density at it crosses its own threshold [V for the dither design; the nesting consequence is my reading, M]. "Locally stable" is in the slide's own goal list [V]. +4.2 Independently generated sets per density level are not nested and change abruptly between levels; one fix is a hierarchy where denser levels grow out from sparser ones [S] 3D-TSV, arXiv 2112.09202 (as found in search; page not read). +4.3 Blue-noise candidates: Wei, "Parallel Poisson disk sampling", ACM TOG 27(3), SIGGRAPH 2008: grid cells, concurrent sampling of non-conflicting cells, adaptive sampling from an importance field, constant passes for a target count [S]. Kopf, Cohen, Deussen, Lischinski, "Recursive Wang Tiles for Real-Time Blue Noise", ACM TOG 25(3):509-518 (2006) (authorship per search; not Lagae-Dutre): tile-based blue noise with spatially varying density; stability by position-indexed tiles is [M]. Ahmed, Niese, Huang, Deussen, "An adaptive point sampler on a regular lattice", ACM TOG 36(4) (2017): one sample per lattice tile, word-based tile IDs, bit-reversal traversal of sub-tiles for varying density [S]. Ulichney void-and-cluster (1993) and Mitchell best-candidate (1991) are the classic ordered-threshold constructions [M]. Hash-per-cell jittered grids are the simplest deterministic, position-stable placement but are not blue noise [M]. +4.4 Spatiotemporal blue noise masks (Wolfe et al., EGSR 2022): values well distributed over time, blue spectra in space and time, built by reformulating the void-and-cluster energy function [V] https://www.ea.com/seed/news/egsr-2022-blue-noise (page read; paper PDF not readable by the fetcher). Aimed at TAA and Monte Carlo, not point placement. +4.5 Render-side anti-popping: dithered LOD cross-fade with the opacity-mask dither threshold ramped over about 0.25-0.5 s (Cesium for Unreal) [S] https://cesium.com/blog/2022/10/20/smoother-lod-transitions-in-cesium-for-unreal/ ; hysteresis offset added to LOD distance to avoid flicker at a boundary (Blender BGE commit, default 10%) [S] https://demo.gitea.com/kannonier8/blender/src/commit/e7d051043dc82c2991eb491e0630d9b1065934c3/release . Side effect: a half-faded LOD still casts shadows/occlusion [S]. +4.6 Time-varying field [M, reasoning; no source found on temporal coherence of density-threshold placement]: with continuous d(x,t) and fixed thresholds, appearance is continuous at the rate of change of d; a fast field (the earlier probe measured flow speed comparable to rail speed, 6.06 vs 6.28 u/s) still moves the set at that rate. Hysteresis needs separate enter/exit thresholds. + +## 5. Predator-prey arrangement as a closed form + +5.1 Ideal free distribution (Fretwell and Lucas 1970, Acta Biotheoretica 19:16-36 [M]): payoff equal and maximal in all occupied patches. With input rates r_i, the fraction in patch i is p_i = r_i / (r_1 + ... + r_n) (Parker's input-matching rule). PW given the resource field plus a global sum for normalisation [S] https://fix.prf.jcu.cz/~krivan/clanky/KrivanCressmanSchneider-TPB-2008.pdf (Krivan, Cressman, Schneider, Theor. Popul. Biol. 73 (2008); PDF fetched, formula seen only in search snippets). +5.2 Predator distribution proportional to prey: x_i = P R_i / sum R_j; with interference exponent m in (0,1), predator density scales as prey density to the power 1/m; the proportional case is globally asymptotically stable under replicator dynamics; input matching is often imperfect, with poor patches over-used because of interference [S] Tsybulin and Zelenchuk 2024, Mathematics 12(2):275 (https://ideas.repec.org/a/gam/jmathe/v12y2024i2p275-d1319317.html ), https://dmorris.lakeheadu.ca/29.pdf . +5.3 Two-trophic-level IFD (both species move): equilibria depend on the functional response; Holling type II destabilises the spatial distribution; some equilibria put all predators in one patch; the theory is developed for two-patch systems [V as to existence of these statements in the Krivan et al. 2008 PDF text, Sec. 3.2 and Fig. 5, details partial]. No continuous-space closed form for both species found [S]. +5.4 Tritrophic IFD test (lady beetles, aphids, beans): predators aggregated in the highest-quality patch as predicted; prey distribution near uniform [S] https://czaw.org/?p=1905 . +5.5 If the prey density is itself a pointwise field (Secs. 1-3), the IFD predator field composes pointwise, with the global sum if totals are held fixed [M, composition]. + +## Pointwise versus path-integrated +PW: Okubo-Weiss W (and Q, lambda2); instantaneous divergence -div u; largest strain eigenvalue (T->0 FTLE); inertial-particle compressibility -tau tr(A^2); slope/curvature of the gravity field; density = f(local quantities) with a fixed per-point threshold; ideal free distribution given a prior field (plus a global sum when totals are fixed). +PI: FTLE and FSLE; density from exp(-integral of div u dt) along a trajectory; steady-state Eulerian density in 2D/3D compressible flow; LCS ridge extraction; Langmuir windrow position unless the roll field is analytic (then PW). From 1f8492c328aa3777a27c9b9d3ca3da97b659e782 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 01:56:55 +0200 Subject: [PATCH 016/769] Research: name the ruling the closed-form placement facts serve; drop a scratch path Co-Authored-By: Claude Opus 5.5 --- docs/research/2026-10-14-fauna-placement-closed-form.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/research/2026-10-14-fauna-placement-closed-form.md b/docs/research/2026-10-14-fauna-placement-closed-form.md index d11922b53..63d9f0209 100644 --- a/docs/research/2026-10-14-fauna-placement-closed-form.md +++ b/docs/research/2026-10-14-fauna-placement-closed-form.md @@ -1,8 +1,8 @@ # Closed-form, runtime-evaluated fauna placement: prior-art facts +nServes ruling `aetheria-fauna:ruling:fauna-placement-ruled` (placement is a deterministic closed form of gravity slope and flow), ahead of fauna target r3. Complements `2026-10-13-fauna-placement-prior-art.md`. Agent eyes-fauna-placement-closed-form, session self-2026-10-14-ag, campaign aetheria-fauna, 2026-10-14. No recommendations. Tags: [V] verified by reading the source text this pass (full text or fetched page). [S] a search-result summary or snippet, page not read in full. [M] from memory or derived by me, unverified. "PW" = pointwise (needs only the field and its spatial derivatives at the point, at one instant). "PI" = path-integrated (needs trajectories, history, or a spatial integral). -Scratch copies of fetched PDFs: C:\Users\Meta\.claude\projects\F--Projects-CultLib\5ea0b87f-66d0-4b24-8d36-45015e4cedc1\tool-results\ (les.txt, drift.txt, g2\*.txt). Already covered by the earlier docs (not repeated): Physarum/MCPM, EcoBrush, D8, Maxent basics, float determinism, boids/LOD, radar/DSL clutter. ## 1. Patchiness from flow: what local quantity predicts aggregation From 198d6e28ddf6c0e3e674125ade40a6766b8fe0d1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 01:57:20 +0200 Subject: [PATCH 017/769] Research: fix header line breaks Co-Authored-By: Claude Opus 5.5 --- docs/research/2026-10-14-fauna-placement-closed-form.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/research/2026-10-14-fauna-placement-closed-form.md b/docs/research/2026-10-14-fauna-placement-closed-form.md index 63d9f0209..7a50bf7b2 100644 --- a/docs/research/2026-10-14-fauna-placement-closed-form.md +++ b/docs/research/2026-10-14-fauna-placement-closed-form.md @@ -1,5 +1,6 @@ # Closed-form, runtime-evaluated fauna placement: prior-art facts -nServes ruling `aetheria-fauna:ruling:fauna-placement-ruled` (placement is a deterministic closed form of gravity slope and flow), ahead of fauna target r3. Complements `2026-10-13-fauna-placement-prior-art.md`. + +Serves ruling `aetheria-fauna:ruling:fauna-placement-ruled` (placement is a deterministic closed form of gravity slope and flow), ahead of fauna target r3. Complements `2026-10-13-fauna-placement-prior-art.md`. Agent eyes-fauna-placement-closed-form, session self-2026-10-14-ag, campaign aetheria-fauna, 2026-10-14. No recommendations. Tags: [V] verified by reading the source text this pass (full text or fetched page). [S] a search-result summary or snippet, page not read in full. [M] from memory or derived by me, unverified. "PW" = pointwise (needs only the field and its spatial derivatives at the point, at one instant). "PI" = path-integrated (needs trajectories, history, or a spatial integral). From 064167bd0666a33768ccbf553a659e66d694d764 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 02:16:36 +0200 Subject: [PATCH 018/769] Fauna target r3: placement is a closed form of slope and flow at run time Applies ruling aetheria-fauna:ruling:fauna-placement-ruled: the target gains a Placement law section and replaces fauna-placement-seeded with the placement invariants closed-form, story, nested and no-pop. The map gains r3 body facts (B24-B27, the closed-form density probe), decisions, the two questions and the r3 cut sequence. Co-Authored-By: Claude Opus 5.5 --- docs/aetheria-fauna-map.md | 74 ++++++++++++++++++++++- docs/aetheria-fauna-target.md | 108 +++++++++++++++++++++++++--------- 2 files changed, 152 insertions(+), 30 deletions(-) diff --git a/docs/aetheria-fauna-map.md b/docs/aetheria-fauna-map.md index 07c1f146e..3827f8f62 100644 --- a/docs/aetheria-fauna-map.md +++ b/docs/aetheria-fauna-map.md @@ -267,7 +267,7 @@ Probed at `1746e8bd`. `aetheria-fauna:question:fauna-placement`, `:fauna-flow-coupling`, `:flow-moves-what`, `:flow-slope-terms`. The options and recommendations are in the mind; the research for the first is the placement prior-art file. -### Cut sequence (r2, one line each; each is mapped when it is about to run) +### Cut sequence r2 (superseded by the r3 sequence below) 1. `fauna-flow-field` (cut spec admitted): the flow's owner moves into `ServerShared` (`FlowField`, `Zone.Flow`), the shader lowers it from zone time, and a Unity parity check pins HLSL to C#. @@ -303,3 +303,75 @@ options and recommendations are in the mind; the research for the first is the p 24. `flow-slope-gradient-parity`: the brush-rendered heightfield and `Zone.GetHeight` agree, so the slope term can be switched on (`flow-slope-terms`). 25. `orbit-sync-helper`: the generic flying aid that holds a ship on the local orbital flow. Waits on an operator yes. + +## r3: placement is a closed form evaluated at run time + +Written by Imagination (agent imagination-aeth-fauna-r3, session self-2026-10-14-ag) against `origin/master` +`b836af48`, with the flow owner read at `eureka/aetheria-fauna-flow-field` `5f5a1e4d`. The operator ruled +`fauna-placement-ruled` against `physarum-at-generation`: placement is "deterministic based on the gravity slope and +flow that makes nice patterns", because "the level is constantly changing". The target is revised to r3 (Placement +law, invariants `fauna-placement-closed-form`, `-story`, `-nested`, `-no-pop`). The B20 probe and the r2 rationale for +a generation pass stand as evidence of what the slime look is; the generation pass itself is dropped. Prior art for +the closed form: `docs/research/2026-10-14-fauna-placement-closed-form.md` (`CF:`). + +### Body facts (r3) + +| # | Question | Result | +|---|---|---| +| B24 | Does the gravity field move? | Yes, in closed form of zone time. Orbit positions are `OrbitData.Evaluate(frac(_time / Period + Phase)) * Distance` plus the parent's position, cached per tick (`Zone.cs:476-497`). `GetHeight` subtracts each gas giant's `RadialWaves(..., _time * speed)` (`Zone.cs:645-655`), and `Sun` derives from `GasGiant` (`Zone.cs:771`), so the sun's well ripples too; `GasGiantData` defaults give wave speed x8 (`ZoneData.cs:199`). The slope at a point is a function of `_time`, and nothing in `GetHeight` can be evaluated at another time without a time parameter. | +| B25 | Which pointwise closed form makes the slime look? (scratch probe, not committed) | A pure-Python transliteration of `FlowField.Global`/`Slope`/`Scroll` at `5f5a1e4d` and `Zone.GetHeight`/`GetNormal`/`PowerPulse`/`RadialWaves` at `b836af48`, on the B20 zone (sun, four planets, a gas giant; radius 2000; live flow scale 512, amplitude 15, scroll speed .025), 256² grid at t = 100 s, float64 (C# is float32). Density images were written per candidate, member-dot images under fixed per-candidate thresholds (jittered 16 u lattice, about 49k candidates), and correlated with the B20 Physarum maps (64x32 blocks). Results follow in the next rows. Script and images: Self's scratch for session self-2026-10-14-ag, `imagination-aeth-fauna-r3/` (`habitat_probe.py`, `density_*.png`, `members_*.png`). | +| B25a | Slope band alone (`c0`: the operator's `Deposition(slope)`, zero past slope .8) | Rings on every planet's shoulder, a bright bridge where two shoulders meet; correlation with Physarum A .517 (B .483), the highest of all candidates. 1,435 members. Changes 3.3 % of members in 1 s and 20 % in 8 s from the wells' ripples alone (B24). | +| B25b | Band times flow convergence (`c1`, gain 3; `c6`, gain 1.5, slope terms on: swirl 6, slide 1.5) | Langmuir-windrow analogue (CF:17-18). The band keeps its rings, cut by the flow's convergence into a crisp triangular texture: `Tri3D` is piecewise linear (`tri`), so its derivatives are piecewise constant and the convergence field is a tiling of hard-edged triangles and diagonal streaks. `c6` correlation .464 (B .449); `c1` .428. Members gather in clumps on the shoulders with thin lines between wells. Under continuous evaluation 20-22 % of members change in 1 s and 89-94 % in 8 s: the flow scrolls a full first-octave phase in 40 s (1 / .025). Convergence std over the zone is .147 /s live and .151 /s with the slope terms on. | +| B25c | Band times flow-along-contour (`c5`, narrow; `c7`, wide) | Members where the flow runs along the well's contour (neither climbing nor falling). Fine scalloped arcs, more organic than `c6`, but sparse (94 members narrow, 395 wide) and unstable: 42-134 % of members change in 1 s. `c7` correlation .480. | +| B25d | Okubo-Weiss strain (`c2`) and gravity saddles (`c3`, `c4`) | `c2` (band times sigmoid of W, CF:11) correlation .507, texture like `c1`. `c3` (negative Hessian determinant of the height: outer shoulders and saddles between wells) makes clean rings with dark gaps and no links, .377. `c4` (`c3` times convergence) .316. | +| B26 | Is Sol a zone the code can tell apart? | No. No `ServerShared` code names Sol (grep of `Sol\b` outside `Solar`/`Solid`/`Solve`); a zone's seed is `GalaxyZone.Name.StableHash()` (`Zone.cs:144`, `CombatSeed`). `fauna-none-in-sol` needs the per-zone environment of `sol-flatspace-zone`. | +| B27 | Is there placement code to cut? | No. Nothing under `Assets/Scripts/ServerShared` or `tests/Aetheria.Shared.Tests` names habitat or fauna at `b836af48`. The generation pass of r2 was never built. | + +### Decisions made here, not forks + +- **Candidates are a hashed jittered lattice with fixed per-candidate thresholds**, not a blue-noise set. It is the + simplest deterministic, position-stable construction (CF:42 [M]); it is HZD's shape (CF:33 [V]), and nesting + follows from the fixed thresholds (CF:40). Blue noise (Wei 2008, recursive Wang tiles, CF:42-43 [S]) buys evenness + the jitter already roughly gives; it can replace the lattice later behind the same `Candidate` function. +- **The hash comes from `SimulationDice.Mix`** on the zone seed, a new `HabitatStream` and the cell, so placement + uses the one dice owner (B9) and no `UnityEngine.Random`. +- **The convergence is normalised by a fixed scale in the settings** (.15 /s, B25b), never by a zone-wide statistic, + which would make the law path-integrated (CF:56). +- **Settings are a record, bound per class later.** `HabitatSettings` lands with `Habitat`; the krill cut binds a + record to the krill field kind. Per-class tuning by eye follows the `flow-slope-terms` pattern. +- **Sol** waits for `sol-flatspace-zone` (B26), which makes Sol host no fauna kind. + +### Questions (admitted in the mind, raised in `cut-fauna-habitat-placement.r1`) + +`aetheria-fauna:question:habitat-density-driver` (which flow quantity shapes the band; the images above are its +evidence) and `aetheria-fauna:question:habitat-no-pop` (how a member is carried through a change of hosting). + +### Cut sequence (r3, one line each; each is mapped when it is about to run) + +1. `fauna-flow-field` (spec admitted; third Soul pass): the flow's owner in `ServerShared` (`FlowField`, `Zone.Flow`). +2. `fauna-habitat-placement` (spec admitted, waits on 1 and the two questions): `Habitat`, `HabitatSettings`, + `Zone.HabitatDensity`/`HabitatPresence`, fixed-threshold candidates, tests and the cost measured. +3. `fauna-krill-swarms`: fauna fields derived per zone (not generated), krill members at hosted candidates, advected + cycles (`fauna-flow-coupling-ruled`), the index provider and `ChunkVisible` through presence. Waits on 1, 2. +4. `fauna-krill-catalog`: AetherDb authors the Krill field kind and its `HabitatSettings`. +5. `fauna-field-presentation`: fauna kinds pick their own look and fade by presence; `SectorRenderer` counts belts only. +6. `fauna-minnow-schools`: minnows, fast milling, a habitat on the krill density. +7. `fauna-anchors`: field frames that move across the flow under class laws (`fauna-motion-model-ruled`). +8. `fauna-sharks-and-whales`: sharks by krill density (ideal free distribution, CF:49-50), whales round gas giants. +9. `fauna-disturbance`: sparse reaction records; schools scatter, sharks veer; reactions are tells. +10. `fauna-attacks-strike`: sharks strike and minnow schools scour, through the ordinary damage path. +11. `fauna-attacks-signal`: whale song as noise in the shared signal space (with aetheria-ew), drifter discharge. +12. `fauna-harvest-goods`: catalog goods per kind and yields through the economy's yield table; fields refill. +13. `fauna-harvest-gear`: resonance fishing, nets and trawls as gear with makers. +14. `fauna-void-spiders`: nets across trojan points, snaring and tethering; silk. +15. `fauna-gas-giant-drifters`: a cloud-deck class bound to gas giants. +16. `fauna-edge-giants`: larger classes far from Sol; the hosted class mix scales with distance from Sol. +17. `fauna-springs`: wormhole mouths raise habitat density (a pointwise term of distance to a mouth). +18. `fauna-genre-uses`: cultivators, necrotech and mecha uses, each with its owner's campaign. +19. `sol-flatspace-zone`: a per-zone environment override; Sol is cloudless, flowless and hosts no fauna kind. +20. `fauna-returns`: what an observer is shown (`fauna-distance-ruled`, `fauna-disguise-path-ruled`). +21. `fauna-in-ew-blend`: fauna background kinds with motion-law envelopes in aetheria-ew's judge. +22. `fauna-krill-obscure`: dense krill thin sight lines through `Zone.Obscuration`. Waits on an operator yes. +23. `flow-moves-matter`: floating items, mines and vented clouds drift with the flow (`flow-moves-what-ruled`). +24. `flow-slope-gradient-parity`: the brush-rendered heightfield and `Zone.GetHeight` agree (`flow-slope-terms`). +25. `orbit-sync-helper`: the generic flying aid that holds a ship on the local orbital flow. Waits on an operator yes. diff --git a/docs/aetheria-fauna-target.md b/docs/aetheria-fauna-target.md index 1b7b6d827..ce78364c9 100644 --- a/docs/aetheria-fauna-target.md +++ b/docs/aetheria-fauna-target.md @@ -1,12 +1,26 @@ # Aetheria spaceborne fauna: target -Campaign `aetheria-fauna`, target content r2 (r1 was admitted from the r0 draft at `f9705d32`; Self admits r2). -Repo `GameCult/Aetheria`, working branch `master`, cut branches `eureka/aetheria-fauna-`. Written by -Imagination (agents imagination-aeth-fauna and imagination-aeth-fauna-r2, session self-2026-10-13-ag2); r2 is -against `origin/master` `1746e8bd`. Body facts and probes are in `docs/aetheria-fauna-map.md` (cited below as -`B`); prior art is `docs/research/2026-10-13-fauna-prior-art.md` (`PA:`; `[M]` items are from memory and -unverified) and `docs/research/2026-10-13-fauna-placement-prior-art.md` (placement). This is the target and the -model page. Open forks are questions in the mind under `aetheria-fauna`. +Campaign `aetheria-fauna`, target content r3 (r1 was admitted from the r0 draft at `f9705d32`, r2 by Self; r3 is +admitted by Imagination). Repo `GameCult/Aetheria`, working branch `master`, cut branches +`eureka/aetheria-fauna-`. Written by Imagination (agents imagination-aeth-fauna, imagination-aeth-fauna-r2 and +imagination-aeth-fauna-r3, sessions self-2026-10-13-ag2 and self-2026-10-14-ag); r3 is against `origin/master` +`b836af48`. Body facts and probes are in `docs/aetheria-fauna-map.md` (cited below as `B`); prior art is +`docs/research/2026-10-13-fauna-prior-art.md` (`PA:`; `[M]` items are from memory and unverified), +`docs/research/2026-10-13-fauna-placement-prior-art.md` (placement by simulation) and +`docs/research/2026-10-14-fauna-placement-closed-form.md` (`CF:`; its tags: `[V]` read, `[S]` search summary +only, `[M]` memory or derivation, unverified). This is the target and the model page. Open forks are questions in +the mind under `aetheria-fauna`. + +r3 applies `fauna-placement-ruled` (2026-10-13), against the recommendation to run a Physarum pass at generation: + +> Nothing we generate ahead of time would suffice, because the level is constantly changing. I'd imagine we can come +> up with something deterministic based on the gravity slope and flow that makes nice patterns. + +Placement is therefore a pure function evaluated when it is asked, never a generated and saved product. The +invariant `fauna-placement-seeded` ("the saved zone pack is the authority") is replaced by the placement invariants +below. r3 also carries the rulings admitted after r2: drift by advected cycles (`fauna-flow-coupling-ruled`), loose +matter drifts with the flow and ships do not (`flow-moves-what-ruled`), the slope terms are turned on +(`flow-slope-terms-ruled`), and presenters take their phase from zone time (`presenter-phase-from-zone-time`). r2 applies the operator's rulings of 2026-10-13: fauna attack ships in various ways (`fauna-interaction-ruled`), are harvestable and prominent in Elysium (`fauna-harvest-ruled`), are placed to tell an ecological story shaped by @@ -59,8 +73,8 @@ gather shows the shape of the wells and the currents, and where they are missing the standard a disguised ship must fly to (`disguise-held-by-flying`). 3. **Each class has one motion law, legible and learnable.** An observer who watches a return for long enough can test it against the class's law. The laws are written to be told apart: - - *krill* clouds ride the local orbital flow, as belt rocks do: their velocity is the flow at their distance. This - is the drifting-insect tell (PA:9); + - *krill* drift with the global flow: each member advects along the flow at its seed point for one cycle, fades + and re-seeds (`fauna-flow-coupling-ruled`). This is the drifting-insect tell (PA:9); - *minnow* schools ride the flow and mill fast inside their school, so their jitter is quick; - *sharks* cut across the flow, patrol and sprint, and accelerate harder than anything else in the class list; - *whales* are slow, huge and hot, and swing in long arcs. @@ -71,8 +85,8 @@ gather shows the shape of the wells and the currents, and where they are missing 100,000 stepped bodies that read the zone force take 16 ms. Neither form scales to fauna counts. 5. **One detection rule.** Fauna are seen through `Sensor.Gain` by way of `Entity.PassiveInfo`, as chunks are (`Entity.ChunkInfo`, B5). There is no second sensor model and no fauna-specific threshold. -6. **Deterministic.** A fauna's state is a function of the zone's seed, its saved field data, zone time and sim - events on fixed steps (`SimClock`, B9). It uses no `UnityEngine.Random`, no wall clock and no frame time. Any dice +6. **Deterministic.** A fauna's state is a function of the zone's seed, its world laws, its bodies' orbits, zone + time and sim events on fixed steps (`SimClock`, B9). Nothing about where fauna are is saved. It uses no `UnityEngine.Random`, no wall clock and no frame time. Any dice come from `SimulationDice` on a stream of their own. 7. **The zone owns fauna; presenters read them.** No Unity callback writes fauna state. The renderer draws them as it draws belts, from a pose evaluated at zone time (`ZoneRenderer.cs:543-569`, B7). @@ -81,19 +95,47 @@ gather shows the shape of the wells and the currents, and where they are missing (`Volumetric.cginc` `flow()`, B15) is evaluated by one `ServerShared` function of position and zone time from the zone's flow settings. Fauna drift with it. The shader lowers the same function from the same settings and time, and a parity check pins the two. No presenter accumulates its own flow time (B16). -10. **Placement tells an ecological story and is decided once.** The generator places fauna from the zone's gravity - and flow, deterministically from the zone seed, and saves what it placed with the zone pack. Fauna gather on - the shoulders of wells and along links between them, not in the cores and not in flat space (placement probe, - `docs/research/2026-10-13-fauna-placement-prior-art.md`). The method is the question `fauna-placement`. +10. **Placement tells an ecological story, and the level writes it as it moves.** Where fauna are is a closed form + of the gravity slope and the flow, evaluated at zone time whenever a query, presenter or sensor asks + (`fauna-placement-ruled`). Wells orbit, gas giants and suns ripple their wells (B24), and the flow scrolls, so the + pattern moves with the level: a placement generated ahead would go stale. Fauna gather on the shoulders of wells, + not in the cores and not in flat space, and the flow writes texture into that band. The law is in "Placement + law" below. 11. **No fauna in Sol.** Sol is a single flatspace zone with no fauna inside it; the strangeness begins at its edge. 12. **Harm and harvest go through existing owners.** A fauna attack is damage through the path a weapon's hit takes; a harvest yields items through the economy's yield path; neither gets a fauna-only damage or loot rule. +## Placement law + +One law decides where fauna of a class are, from local quantities only (pointwise: the field and its derivatives at +a point, CF:6, CF:56): + +1. **A habitat density** in [0, 1] at a point, from the gravity slope there and the flow there. The slope term is a + band on the shoulders of the wells: the operator's own `Deposition(slope)` curve from her slime prefab (B20), zero + past the steep core and near zero in flat space. Which flow quantity shapes the band is the question + `habitat-density-driver`; the probe images are in the map (B25). +2. **Candidates with fixed thresholds.** Each class has a lattice of candidate seed points, one per cell, jittered by + a hash of the zone seed and the cell, each with its own fixed threshold from the same hash. A candidate hosts a + member when the density at it is above its threshold. This is Horizon Zero Dawn's placement shape: a density map + discretised by a fixed threshold pattern, deterministic and "locally stable" (CF:33 [V]). Because each threshold + is fixed, raising the density never removes a member, and a member appears or leaves only when the density at + its own point crosses its own threshold (CF:40: the design is [V], the nesting is a reading [M], so it is pinned + here as an invariant instead of trusted). The member's identity is the cell, so it is a `ChunkId` with no stored + state. +3. **No pop.** The density moves as fast as the flow scrolls (B25: under continuous evaluation a fifth of a + flow-shaped population changes in one second). A member never appears or vanishes at full presence; how it is + carried through a change is the question `habitat-no-pop`. +4. **Predators by prey.** Classes that hunt read their prey's density, not a second map: the ideal free + distribution puts predators in proportion to prey (CF:49-50, [S]; composed pointwise, CF:53 [M]). Sharks on krill + shoulders are one product of two pointwise densities. + +The law is pure and costs nothing per tick: it runs per candidate when asked, as `Zone.ChunkPose` does for rocks. + ## Scale classes | Class | Shape in the sim | Light | Motion law (tell) | Prop role | |---|---|---|---|---| -| Krill | A cloud of thousands of members that share one orbital slot; members sway within a bounded radius | Faint reflection per member; the cloud is bright in aggregate | Velocity equals the local orbital flow; slow bounded sway | Hide in a cloud; later, a dense cloud thins sight lines as a vapour cloud does (`Zone.Obscuration`) | +| Krill | Thousands of members at hosted candidates on the well shoulders; each advects along the flow for one cycle, fades and re-seeds | Faint reflection per member; a patch is bright in aggregate | Drifts with the global flow (advected cycles) | Hide in a cloud; later, a dense cloud thins sight lines as a vapour cloud does (`Zone.Obscuration`) | | Minnow | Schools of hundreds that ride the flow | Faint reflection, a bright school | Flow plus fast milling inside the school | Clutter of small returns; a ship moving through a school is noticed (later: scatter) | | Shark | One to three members, each about the size of a ship | Reflection and moderate thermal | Crosses the flow; patrols with sprints | A ship-sized return a hunter must think about; the best disguise target for a fighter | | Whale | One member, many times a ship's size | Strong thermal; heard song (later) | Slow long arcs | A bright anchor; a ship near it is hard to separate (later: shadowing) | @@ -115,14 +157,14 @@ The rows follow the rulings `fauna-in-sim-ruled`, `fauna-identity-ruled` and `fa | Kind | Identity (what names it) | Lifecycle | Authority (who decides; forbidden writers) | |---|---|---|---| | **Field kind** (existing, extended) | `FieldKindData` catalog record (`ZoneData.cs:122`), by key. A fauna kind is a field kind with a non-zero member sway and zero belt `GenerationWeight`, so a rock belt never draws it (`FieldKinds.Assign`, "Zero never picks it"). | Authored in AetherDb, append-only MessagePack keys. | The catalog. Forbidden: runtime writes. | -| **Swarm** (new use of an existing kind) | A field: an `AsteroidBeltData` body in `ZonePack.Planets`, named by its record key, as a belt is. Its members have orbital elements (distance and phase) clustered into one arc, and its kind is a fauna kind. | Created by `ZoneGenerator.GenerateZone` and saved with the zone pack, like a belt. Members are never saved except for their wear. | `ZoneGenerator` creates it; `Zone` holds it (`AsteroidBelts`). Forbidden: presenters, entities. | -| **Member** (a chunk of a fauna field) | `ChunkId(field, index)` (`Zone.cs:825`; the type was named `Field` so other field kinds could join, per its own comment). | Exists unless broken (`Zone.ChunkExists`). Wear and respawn are lazy comparisons against zone time (`Zone.Wear`), as for rocks. Its pose is a pure function of zone time: the orbital slot plus the kind's bounded sway. | `AsteroidBelt.Pose` alone computes the pose; `Zone.Wear` is the only writer of damage. Forbidden: any stored pose, any per-tick member loop. | +| **Fauna field** (one per fauna kind a zone hosts) | The fauna kind's catalog key within the zone: a zone hosts every fauna kind its world laws admit, so the field is derived, not generated. | Derived when the zone is built, from its world laws and the catalog; never saved. Only its members' wear is saved (`ZonePack.ChunkWear`). | `Zone`. Forbidden: `ZoneGenerator` writing fauna into the pack; presenters; entities. | +| **Member** (a chunk of a fauna field) | `ChunkId(field, index)` (`Zone.cs:825`; the type was named `Field` so other field kinds could join, per its own comment); the index is a lattice cell. | Present while its candidate is hosted (placement law) and it is not broken (`Zone.ChunkExists`). Wear and respawn are lazy comparisons against zone time (`Zone.Wear`), as for rocks. Its pose is a pure function of zone time: its seed point advected along the flow for its cycle. | The habitat law decides hosting; the class law alone computes the pose; `Zone.Wear` is the only writer of damage. Forbidden: any stored pose or hosting bit, any per-tick member loop. | | **Fauna provider** (existing, widened) | `BeltTargets`, one per field (`Zone.cs:175`). | Built with the zone, never saved. | Its region bounds must hold for every member. Sway widens the slack by the kind's amplitude. Forbidden: a second index or a fauna scan. | | **Fauna detection** (derived) | `(observer, member)`, never stored. | Derived per query: `ChunkInfo` → `PassiveInfo` → `Sensor.Gain`. | `Entity`. Forbidden: any fauna-specific threshold or gain. | | **Swarm anchor** (later, sharks and whales) | Per field: a law that moves the field's frame across the flow. | Pure function of zone time plus sparse event records (fork `fauna-motion-model`). | `Zone`. Forbidden: per-member stepping. | | **Disturbance** (later) | `(member, time, push)`, sparse, as `ChunkWear` is. | Written when a hull passes through or a shot lands, then decays lazily against zone time. | `Zone`. It is read only by `Pose`. | | **Flow field** (new owner) | One law: `FlowField`, evaluated per zone by `Zone.Flow(position)` from the zone's world-law settings (`PlanetSettings.Flow`). | Settings authored in `Settings.asset`; time is zone time. Never stored per position. | `Zone`. Derived: every shader flow uniform (`_FlowScroll` and the rest). Forbidden: `VolumeSampling._flowScroll`, `ZoneEnvironment.Flow`, any frame-time accumulator. | -| **Habitat placement** (generation intermediate) | None: it exists only inside `ZoneGenerator.GenerateZone`. | Computed once per zone from the seed, gravity and flow; consumed to place fields; discarded. | `ZoneGenerator`. Its products, fauna fields, are saved as belts are. Forbidden: recomputing on load, saving the intermediate map. | +| **Habitat** (new owner, a law) | `Habitat` in `ServerShared`: a class's `HabitatSettings`, a point's slope and flow quantities, a candidate's hashed seed point and threshold. | Never stored: evaluated at zone time per candidate when asked. | `Habitat` decides density and hosting; `Zone` supplies the local quantities from `GetNormal` and `Flow`. Forbidden: a generation pass, a saved or cached habitat map, a per-tick sweep. | | **Attack** (later, per class) | `(member or swarm, ship, time)`, an event. | Raised by a class law when its trigger holds; applied through the ordinary damage path. | `Zone`. Forbidden: a fauna-only damage rule. | | **Harvest yield** (later) | Item kinds in the catalog, per fauna kind. | A member broken or netted by harvesting gear yields items through the economy's yield table; the field refills by respawn. | The catalog and the yield table. Forbidden: fauna-only loot code. | | **Fauna background kind** (aetheria-ew) | Entries of aetheria-ew's `BackgroundKind` for fauna classes (that target's row says "more by ruling"). | Static data. | `GameplaySettings`, per aetheria-ew. Its envelope gains a motion term per class (fork `fauna-disguise-path`). | @@ -137,8 +179,8 @@ The rows follow the rulings `fauna-in-sim-ruled`, `fauna-identity-ruled` and `fa `Examined` counter and by `Zone.Update` touching no member, not by timing. - `fauna-one-gain`: a fauna member is visible exactly when `ChunkVisible` says so. - `fauna-bounds-hold`: every member of a swaying or drifting field lies inside the regions its provider offers. -- `fauna-tell-holds`: each class's members move by that class's law; a krill cloud moves with the orbital flow and - the global flow, within its sway bound. Each class gets its own tell test. +- `fauna-tell-holds`: each class's members move by that class's law; a krill member advects with the global flow + from its seed point for its cycle. Each class gets its own tell test. - `fauna-never-lie`: no claim, suppression or belief is written for a fauna. - `flow-one-owner`: the global flow has one owner, `FlowField` in `ServerShared`, evaluated at zone time from the zone's flow settings. Every shader flow uniform is derived from it; no presenter keeps its own flow clock. @@ -146,22 +188,30 @@ The rows follow the rulings `fauna-in-sim-ruled`, `fauna-identity-ruled` and `fa the same position, time, settings and gradient, within a stated tolerance; a parity check that evaluates both pins it and fails when either side changes alone. - `flow-step-free`: the flow at a position and zone time does not depend on step size or frame rate. -- `fauna-none-in-sol`: Sol's zone holds no fauna field; generation never places one there. -- `fauna-placement-seeded`: a zone's fauna placement is a function of its seed and its generated bodies on the - generating runtime; the saved zone pack is the authority after that, and loading never recomputes placement. +- `fauna-none-in-sol`: Sol's zone hosts no fauna kind, so no candidate there is ever hosted. +- `fauna-placement-closed-form`: whether a candidate hosts a member at zone time t is a pure function of the zone + seed, the class's habitat settings, and the gravity slope and flow at the candidate at t. Nothing about placement + is generated ahead, saved, cached across ticks or stepped. +- `fauna-placement-story`: habitat density is zero in well cores, below every candidate's threshold in flat space, + and highest on well shoulders, and it changes when the slope or the flow at the point changes. +- `fauna-placement-nested`: each candidate's threshold is fixed, so raising the density at a candidate never + un-hosts it and lowering it never hosts it. +- `fauna-placement-no-pop`: a member never appears or vanishes at full presence; every change of hosting is carried + through a fade. ## Not in scope -- Ambient presentation-only life (the slime trails) beyond leaving it untouched; its rules may be reused at - generation, but the presenter stays presentation. +- Ambient presentation-only life (the slime trails) beyond leaving it untouched; the habitat law reuses its + `Deposition(slope)` curve, but the presenter stays presentation. - Phantom positions (aetheria-ew `ew-where-lies-ruled`). -- Ships, floating items, mines and clouds drifting with the flow, until `flow-moves-what` is ruled. +- Ships drifting with the flow (`flow-moves-what-ruled`: ships do not; floating items, mines and vented clouds do, + in a later cut). - Per-zone flow and nebula environments; Sol's cloudless bubble is a later cut, and until it lands every zone shares one flow. Attacks and harvest are in scope as later cuts, designed per class (`fauna-interaction-ruled`, -`fauna-harvest-ruled`); the first cuts build the ground they stand on: the flow, placement and the krill field. +`fauna-harvest-ruled`); the first cuts build the ground they stand on: the flow, the habitat law and the krill field. ## Cut sequence -The map has the first cut drafted as a spec section and every later cut as one line. +The map's r3 cut sequence has one line per cut; each is mapped when it is about to run. From e760333bafab0da8e0e1c7bb4de469d21764fb2d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 02:22:51 +0200 Subject: [PATCH 019/769] Fauna map r3: presence-fade and cycle-sampled pop probes (B25e, B25f) Co-Authored-By: Claude Opus 5.5 --- docs/aetheria-fauna-map.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/aetheria-fauna-map.md b/docs/aetheria-fauna-map.md index 3827f8f62..62bb3e389 100644 --- a/docs/aetheria-fauna-map.md +++ b/docs/aetheria-fauna-map.md @@ -324,6 +324,8 @@ the closed form: `docs/research/2026-10-14-fauna-placement-closed-form.md` (`CF: | B25b | Band times flow convergence (`c1`, gain 3; `c6`, gain 1.5, slope terms on: swirl 6, slide 1.5) | Langmuir-windrow analogue (CF:17-18). The band keeps its rings, cut by the flow's convergence into a crisp triangular texture: `Tri3D` is piecewise linear (`tri`), so its derivatives are piecewise constant and the convergence field is a tiling of hard-edged triangles and diagonal streaks. `c6` correlation .464 (B .449); `c1` .428. Members gather in clumps on the shoulders with thin lines between wells. Under continuous evaluation 20-22 % of members change in 1 s and 89-94 % in 8 s: the flow scrolls a full first-octave phase in 40 s (1 / .025). Convergence std over the zone is .147 /s live and .151 /s with the slope terms on. | | B25c | Band times flow-along-contour (`c5`, narrow; `c7`, wide) | Members where the flow runs along the well's contour (neither climbing nor falling). Fine scalloped arcs, more organic than `c6`, but sparse (94 members narrow, 395 wide) and unstable: 42-134 % of members change in 1 s. `c7` correlation .480. | | B25d | Okubo-Weiss strain (`c2`) and gravity saddles (`c3`, `c4`) | `c2` (band times sigmoid of W, CF:11) correlation .507, texture like `c1`. `c3` (negative Hessian determinant of the height: outer shoulders and saddles between wells) makes clean rings with dark gaps and no links, .377. `c4` (`c3` times convergence) .316. | +| B25e | Does a presence fade on the density stop pops? (`pop_probe.py`) | No. `c6` with a fade band of .1 around each threshold, 600 shoulder candidates, 180 steps of 1/60 s from t = 100: the density moves by up to .267 in one step, so presence moves by up to .35 in one step (4 steps over .25; .56 with a band of .05). The same with the slope terms off, so the jumps are the global `Tri3D` term's: its convergence changes almost discontinuously in time at some points. | +| B25f | Does reading the flow term at the member's own cycle start stop them? (`pop_cycle.py`) | Yes. Band at now, convergence at the member's cycle start (period 8 s, hashed phase), same fade and candidates: presence moves by at most .0003 per step within a cycle, 0 steps over .25; the flow term changes only at the 221 cycle turns, where `fauna-flow-coupling-ruled` already fades the member. The slope band moves smoothly (B24's ripples are continuous). `FlowField.At` takes a scroll, and `FlowField.Scroll(settings, t)` is pure in t, so the flow at a past zone time needs no new owner. | | B26 | Is Sol a zone the code can tell apart? | No. No `ServerShared` code names Sol (grep of `Sol\b` outside `Solar`/`Solid`/`Solve`); a zone's seed is `GalaxyZone.Name.StableHash()` (`Zone.cs:144`, `CombatSeed`). `fauna-none-in-sol` needs the per-zone environment of `sol-flatspace-zone`. | | B27 | Is there placement code to cut? | No. Nothing under `Assets/Scripts/ServerShared` or `tests/Aetheria.Shared.Tests` names habitat or fauna at `b836af48`. The generation pass of r2 was never built. | From 417dd6c94a9d4075b89517aa79a47a3820fc5acb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 02:30:21 +0200 Subject: [PATCH 020/769] Fauna habitat probe kept as research; option images; lattice verdict (B25g, B25h) Moves the closed-form habitat probe scripts and the images each habitat question option names into docs/research/2026-10-14-fauna-habitat-probe/, points the map there, and adds the option renders under the spec's law and the probe that shows the convergence lattice is FlowField's own structure. Co-Authored-By: Claude Opus 5.5 --- docs/aetheria-fauna-map.md | 8 +- .../2026-10-14-fauna-habitat-probe/README.md | 6 + .../density_c0_slope_band_live.png | 3 + .../density_c1_windrows_live.png | 3 + .../density_c2_strain_live.png | 3 + .../density_c5_contour_currents_swirl.png | 3 + .../density_c6_band_soft_windrows_swirl.png | 3 + .../density_c7_band_wide_contour_swirl.png | 3 + .../habitat_options.py | 69 ++++++ .../habitat_probe.py | 211 ++++++++++++++++++ .../lattice_probe.py | 64 ++++++ .../members_c0_slope_band.png | 3 + .../option_density_band-only.png | 3 + ...option_density_band-times-contour-flow.png | 3 + .../option_density_band-times-convergence.png | 3 + .../option_density_band-times-strain.png | 3 + .../option_members_band-only.png | 3 + ...option_members_band-times-contour-flow.png | 3 + .../option_members_band-times-convergence.png | 3 + .../option_members_band-times-strain.png | 3 + .../option_nopop_cycle-sampled-all.png | 3 + .../option_nopop_cycle-sampled-flow.png | 3 + .../option_nopop_presence-fade.png | 3 + .../pop_cycle.py | 32 +++ .../pop_maps.py | 60 +++++ .../pop_probe.py | 28 +++ 26 files changed, 529 insertions(+), 3 deletions(-) create mode 100644 docs/research/2026-10-14-fauna-habitat-probe/README.md create mode 100644 docs/research/2026-10-14-fauna-habitat-probe/density_c0_slope_band_live.png create mode 100644 docs/research/2026-10-14-fauna-habitat-probe/density_c1_windrows_live.png create mode 100644 docs/research/2026-10-14-fauna-habitat-probe/density_c2_strain_live.png create mode 100644 docs/research/2026-10-14-fauna-habitat-probe/density_c5_contour_currents_swirl.png create mode 100644 docs/research/2026-10-14-fauna-habitat-probe/density_c6_band_soft_windrows_swirl.png create mode 100644 docs/research/2026-10-14-fauna-habitat-probe/density_c7_band_wide_contour_swirl.png create mode 100644 docs/research/2026-10-14-fauna-habitat-probe/habitat_options.py create mode 100644 docs/research/2026-10-14-fauna-habitat-probe/habitat_probe.py create mode 100644 docs/research/2026-10-14-fauna-habitat-probe/lattice_probe.py create mode 100644 docs/research/2026-10-14-fauna-habitat-probe/members_c0_slope_band.png create mode 100644 docs/research/2026-10-14-fauna-habitat-probe/option_density_band-only.png create mode 100644 docs/research/2026-10-14-fauna-habitat-probe/option_density_band-times-contour-flow.png create mode 100644 docs/research/2026-10-14-fauna-habitat-probe/option_density_band-times-convergence.png create mode 100644 docs/research/2026-10-14-fauna-habitat-probe/option_density_band-times-strain.png create mode 100644 docs/research/2026-10-14-fauna-habitat-probe/option_members_band-only.png create mode 100644 docs/research/2026-10-14-fauna-habitat-probe/option_members_band-times-contour-flow.png create mode 100644 docs/research/2026-10-14-fauna-habitat-probe/option_members_band-times-convergence.png create mode 100644 docs/research/2026-10-14-fauna-habitat-probe/option_members_band-times-strain.png create mode 100644 docs/research/2026-10-14-fauna-habitat-probe/option_nopop_cycle-sampled-all.png create mode 100644 docs/research/2026-10-14-fauna-habitat-probe/option_nopop_cycle-sampled-flow.png create mode 100644 docs/research/2026-10-14-fauna-habitat-probe/option_nopop_presence-fade.png create mode 100644 docs/research/2026-10-14-fauna-habitat-probe/pop_cycle.py create mode 100644 docs/research/2026-10-14-fauna-habitat-probe/pop_maps.py create mode 100644 docs/research/2026-10-14-fauna-habitat-probe/pop_probe.py diff --git a/docs/aetheria-fauna-map.md b/docs/aetheria-fauna-map.md index 62bb3e389..b35bab9d3 100644 --- a/docs/aetheria-fauna-map.md +++ b/docs/aetheria-fauna-map.md @@ -319,13 +319,15 @@ the closed form: `docs/research/2026-10-14-fauna-placement-closed-form.md` (`CF: | # | Question | Result | |---|---|---| | B24 | Does the gravity field move? | Yes, in closed form of zone time. Orbit positions are `OrbitData.Evaluate(frac(_time / Period + Phase)) * Distance` plus the parent's position, cached per tick (`Zone.cs:476-497`). `GetHeight` subtracts each gas giant's `RadialWaves(..., _time * speed)` (`Zone.cs:645-655`), and `Sun` derives from `GasGiant` (`Zone.cs:771`), so the sun's well ripples too; `GasGiantData` defaults give wave speed x8 (`ZoneData.cs:199`). The slope at a point is a function of `_time`, and nothing in `GetHeight` can be evaluated at another time without a time parameter. | -| B25 | Which pointwise closed form makes the slime look? (scratch probe, not committed) | A pure-Python transliteration of `FlowField.Global`/`Slope`/`Scroll` at `5f5a1e4d` and `Zone.GetHeight`/`GetNormal`/`PowerPulse`/`RadialWaves` at `b836af48`, on the B20 zone (sun, four planets, a gas giant; radius 2000; live flow scale 512, amplitude 15, scroll speed .025), 256² grid at t = 100 s, float64 (C# is float32). Density images were written per candidate, member-dot images under fixed per-candidate thresholds (jittered 16 u lattice, about 49k candidates), and correlated with the B20 Physarum maps (64x32 blocks). Results follow in the next rows. Script and images: Self's scratch for session self-2026-10-14-ag, `imagination-aeth-fauna-r3/` (`habitat_probe.py`, `density_*.png`, `members_*.png`). | +| B25 | Which pointwise closed form makes the slime look? (scratch probe, not committed) | A pure-Python transliteration of `FlowField.Global`/`Slope`/`Scroll` at `5f5a1e4d` and `Zone.GetHeight`/`GetNormal`/`PowerPulse`/`RadialWaves` at `b836af48`, on the B20 zone (sun, four planets, a gas giant; radius 2000; live flow scale 512, amplitude 15, scroll speed .025), 256² grid at t = 100 s, float64 (C# is float32). Density images were written per candidate, member-dot images under fixed per-candidate thresholds (jittered 16 u lattice, about 49k candidates), and correlated with the B20 Physarum maps (64x32 blocks). Results follow in the next rows. Scripts, images and how to rerun: `docs/research/2026-10-14-fauna-habitat-probe/` (README there). These rows' member counts normalise density by its zone maximum, which the law may not do (CF:56); B25g gives the counts under the spec's law. The first `members_c6_band_soft_windrows_swirl.png` was a mislabelled render and is not kept. | | B25a | Slope band alone (`c0`: the operator's `Deposition(slope)`, zero past slope .8) | Rings on every planet's shoulder, a bright bridge where two shoulders meet; correlation with Physarum A .517 (B .483), the highest of all candidates. 1,435 members. Changes 3.3 % of members in 1 s and 20 % in 8 s from the wells' ripples alone (B24). | | B25b | Band times flow convergence (`c1`, gain 3; `c6`, gain 1.5, slope terms on: swirl 6, slide 1.5) | Langmuir-windrow analogue (CF:17-18). The band keeps its rings, cut by the flow's convergence into a crisp triangular texture: `Tri3D` is piecewise linear (`tri`), so its derivatives are piecewise constant and the convergence field is a tiling of hard-edged triangles and diagonal streaks. `c6` correlation .464 (B .449); `c1` .428. Members gather in clumps on the shoulders with thin lines between wells. Under continuous evaluation 20-22 % of members change in 1 s and 89-94 % in 8 s: the flow scrolls a full first-octave phase in 40 s (1 / .025). Convergence std over the zone is .147 /s live and .151 /s with the slope terms on. | | B25c | Band times flow-along-contour (`c5`, narrow; `c7`, wide) | Members where the flow runs along the well's contour (neither climbing nor falling). Fine scalloped arcs, more organic than `c6`, but sparse (94 members narrow, 395 wide) and unstable: 42-134 % of members change in 1 s. `c7` correlation .480. | | B25d | Okubo-Weiss strain (`c2`) and gravity saddles (`c3`, `c4`) | `c2` (band times sigmoid of W, CF:11) correlation .507, texture like `c1`. `c3` (negative Hessian determinant of the height: outer shoulders and saddles between wells) makes clean rings with dark gaps and no links, .377. `c4` (`c3` times convergence) .316. | -| B25e | Does a presence fade on the density stop pops? (`pop_probe.py`) | No. `c6` with a fade band of .1 around each threshold, 600 shoulder candidates, 180 steps of 1/60 s from t = 100: the density moves by up to .267 in one step, so presence moves by up to .35 in one step (4 steps over .25; .56 with a band of .05). The same with the slope terms off, so the jumps are the global `Tri3D` term's: its convergence changes almost discontinuously in time at some points. | -| B25f | Does reading the flow term at the member's own cycle start stop them? (`pop_cycle.py`) | Yes. Band at now, convergence at the member's cycle start (period 8 s, hashed phase), same fade and candidates: presence moves by at most .0003 per step within a cycle, 0 steps over .25; the flow term changes only at the 221 cycle turns, where `fauna-flow-coupling-ruled` already fades the member. The slope band moves smoothly (B24's ripples are continuous). `FlowField.At` takes a scroll, and `FlowField.Scroll(settings, t)` is pure in t, so the flow at a past zone time needs no new owner. | +| B25e | Does a presence fade on the density stop pops? (`docs/research/2026-10-14-fauna-habitat-probe/pop_probe.py`) | No. `c6` with a fade band of .1 around each threshold, 600 shoulder candidates, 180 steps of 1/60 s from t = 100: the density moves by up to .267 in one step, so presence moves by up to .35 in one step (4 steps over .25; .56 with a band of .05). The same with the slope terms off, so the jumps are the global `Tri3D` term's: its convergence changes almost discontinuously in time at some points. | +| B25f | Does reading the flow term at the member's own cycle start stop them? (`docs/research/2026-10-14-fauna-habitat-probe/pop_cycle.py`) | Yes. Band at now, convergence at the member's cycle start (period 8 s, hashed phase), same fade and candidates: presence moves by at most .0003 per step within a cycle, 0 steps over .25; the flow term changes only at the 221 cycle turns, where `fauna-flow-coupling-ruled` already fades the member. The slope band moves smoothly (B24's ripples are continuous). `FlowField.At` takes a scroll, and `FlowField.Scroll(settings, t)` is pure in t, so the flow at a past zone time needs no new owner. | +| B25g | The options under the spec's law (`habitat_options.py`, `pop_maps.py`) | Absolute density, fixed constants of `cut-fauna-habitat-placement.r1`, candidate thresholds .1 + .9h, fade .1, slope terms on, 49,083 candidates at 16 u. Hosted at t = 100 and changed under continuous evaluation in 1 s / 8 s: band-only 4,388, 2.0 % / 12.2 %; band-times-convergence 2,235, 10.3 % / 47.1 %; band-times-strain 2,721, 7.9 % / 31.3 %; band-times-contour-flow 2,307, 15.8 % / 49.2 %. No-pop, convergence law, 5,607 shoulder candidates, 60 steps of 1/60 s, largest presence step with cycle turns excluded and members moving > .1 in a step: presence-fade .33, 2 members; cycle-sampled-flow .0010, 0; cycle-sampled-all .0000, 0. Images: `option_density_