From 8f42265de33146deffaef437f2b8567e1db18263 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 14:18:41 +0200 Subject: [PATCH 01/35] 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 02/35] 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 03/35] 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 04/35] 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 05/35] 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 06/35] 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 07/35] 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 08/35] 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 09/35] 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 10/35] 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 11/35] 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 12/35] 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 13/35] 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 14/35] 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 04516c72cf2c4a835818a5eb22b84871fcf3f4a9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 20:31:41 +0200 Subject: [PATCH 15/35] Faction play 1: s1/s2 fix batch (Engaged-first anchor, doctrine pins, follow verb) Flight anchors a blind member on an Engaged track before a nearer Hailing one and keeps its anchor only while nothing outranks it. Doctrine.cs states the trespass rule as the zone owner's permission. DoctrineCatalog matches the faction by exact name and its comment names the landed Pirates record. VerseGrammarCommand gains the follow verb. Tests pin default and authored standoff, Grace against ComplySpeed, nearest hail, the Identified boundary, off-origin direction and anchor choice and switch, the unseen-member rule across geometry, combat from follow, the doctrine-catalog dry run and the turret's global hit threshold. Not yet run: this commit's tests have not been built. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Agents/Flight.cs | 6 +- Assets/Scripts/ServerShared/Doctrine.cs | 2 +- .../FireControlPerWeaponTests.cs | 41 ++++ tests/Aetheria.Shared.Tests/FlightTests.cs | 197 +++++++++++++++--- tools/AetherDb/DoctrineCatalog.cs | 6 +- tools/AetherDb/VerseGrammarCommand.cs | 2 + 6 files changed, 221 insertions(+), 33 deletions(-) diff --git a/Assets/Scripts/ServerShared/Agents/Flight.cs b/Assets/Scripts/ServerShared/Agents/Flight.cs index 965aa0cd9..ab3550b2b 100644 --- a/Assets/Scripts/ServerShared/Agents/Flight.cs +++ b/Assets/Scripts/ServerShared/Agents/Flight.cs @@ -156,9 +156,13 @@ public class Flight var held = picture.Where(e => _tracks[e].Phase != Phase.None).ToList(); if (held.Count > 0) { + // An engaged track outranks a hailed one; the held anchor is kept only while nothing outranks it. + var anyEngaged = held.Any(e => _tracks[e].Phase == Phase.Engaged); var anchor = member.Agent.Task is FollowTask follow && held.Contains(follow.Anchor) + && (!anyEngaged || _tracks[follow.Anchor].Phase == Phase.Engaged) ? follow.Anchor - : held.OrderBy(e => lengthsq(ship.Position.xz - e.Position.xz)).First(); + : held.OrderBy(e => _tracks[e].Phase == Phase.Engaged ? 0 : 1) + .ThenBy(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) }; } diff --git a/Assets/Scripts/ServerShared/Doctrine.cs b/Assets/Scripts/ServerShared/Doctrine.cs index 40ce188e0..4a9b636ed 100644 --- a/Assets/Scripts/ServerShared/Doctrine.cs +++ b/Assets/Scripts/ServerShared/Doctrine.cs @@ -15,7 +15,7 @@ public enum EngageOn 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. + // A hostile whose presence the zone's owner does not permit (Entity.PresencePermitted, the security of the zone it is in), whichever flight sees it. Trespass, // Never, not even for a grudge. Never diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index d6efd6847..5648c0db8 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -314,6 +314,47 @@ public sealed class FireControlPerWeaponTests : IDisposable Assert.True(r.Gun.Weapon.Firing, "the turret must fire a fused weapon at a designated target out of arc"); } + // The turret keeps the AgentMinHitProbability threshold for a direct-hit weapon: a shot priced near .5 is held at a + // threshold of .6 and taken at .4. Kills: the turret's fire threshold dropped to zero or fixed to a constant. + [Theory] + [InlineData(.6f, false)] + [InlineData(.4f, true)] + public void ATurretKeepsTheGlobalHitProbabilityThresholdForADirectHitWeapon(float minHit, bool fires) + { + var r = Build(accuracy: .5f, minHit: minHit, turret: true); + var p = FireControl.HitProbability(r.Gun.Weapon, r.Shooter, r.Target); + Assert.True(p > .4f && p < .6f, $"fixture: the shot must price near .5, priced {p}"); + + r.Zone.Update(1f); + r.Zone.Update(1f); + + Assert.Equal(fires, r.Gun.Weapon.Firing); + } + + // A flight's members hold off at 1.1 x their longest gun when the doctrine authors no standoff (every authored + // doctrine leaves it 0), and at the authored distance otherwise. Guns of Range 50 and 100 on one ship. Kills: the + // default zeroed, inverted, taken from the shortest gun, or the authored value ignored. + [Theory] + [InlineData(0f, 110f)] + [InlineData(40f, 40f)] + public void AFlightHoldsOffAtTheDefaultOrAuthoredStandoff(float holdStandoff, float expected) + { + var r = Build(range: 50f, decoy: true); + r.Shooter.SetIff(r.Target, null); + if (!r.Shooter.VisibleEnemies.Contains(r.Target)) r.Shooter.VisibleEnemies.Add(r.Target); + var settings = r.Shooter.ItemManager.GameplaySettings; + var flight = new Flight(new FactionDoctrine + { + EngageOn = EngageOn.Detection, Grace = 5, Combatant = new RoleDoctrine { MinHitProbability = .2f, HoldStandoff = holdStandoff } + }, settings); + var agent = new Minion(r.Shooter, flight.Combatant); + flight.Join(agent, new PatrolOrbitsTask()); + + flight.Update(.1f); + + Assert.Equal(expected, ((FollowTask) agent.Task).Standoff, 2); + } + // The AI aims at the height a target rides at (Entity.RideHeight: field plus the layer the target's kind rides on), // not at the plane and not at the shooter's own layer. A target on a layer 40 u up against a shooter on the ship // layer, a shot fast enough to be predicted: the turret's and Combat's aim point at the target's stepped pose. diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs index 89bfd2b30..896012c53 100644 --- a/tests/Aetheria.Shared.Tests/FlightTests.cs +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -33,17 +33,17 @@ public sealed class FlightTests : IDisposable } }; - private Ship Pilot(Faction faction, float x = 0) + private Ship Pilot(Faction faction, float x = 0, float z = 0) { var ship = _world.NewShip(_items, _zone, faction, piloted: true); - ship.Position = new float3(x, 0, 0); + ship.Position = new float3(x, 0, z); return ship; } - private Ship Track(float x = 100) + private Ship Track(float x = 100, float z = 0) { var track = _world.NewShip(_items, _zone, Hostile); - track.Position = new float3(x, 0, 0); + track.Position = new float3(x, 0, z); return track; } @@ -102,7 +102,7 @@ public sealed class FlightTests : IDisposable Assert.True(a.Target.Value.IsNone); Assert.True(b.Target.Value.IsNone); - b.EntityInfoGathered[track] = .9f; + b.EntityInfoGathered[track] = .8f; // exactly the threshold Tick(.1f); Assert.Same(track, a.Target.Value.Entity); Assert.Same(track, b.Target.Value.Entity); @@ -140,8 +140,8 @@ public sealed class FlightTests : IDisposable public void GraceHailsOnceFromTheNearestThenEngages() { var faction = WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold."); - var near = Pilot(faction, 90); var far = Pilot(faction, 0); + var near = Pilot(faction, 90); // built second: the nearest, not the first built, speaks var track = Track(100); See(near, track); See(far, track); @@ -181,6 +181,27 @@ public sealed class FlightTests : IDisposable Assert.Empty(member.Messages); } + // Grace 3 against ComplySpeed 10: a track at 5 is slower than ComplySpeed (complied) though faster than the Grace + // number, and one at 12 is faster than both. Kills: either comparison reading Grace for ComplySpeed. + [Theory] + [InlineData(5f, false)] + [InlineData(12f, true)] + public void ComplianceIsJudgedOnComplySpeedAndNotOnGrace(float speed, bool engaged) + { + var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 3, hail: "Hold.", comply: 10)); + var track = Track(); + track.Velocity = new float2(speed, 0); + See(member, track); + + Tick(.5f); + Tick(3.5f); + Assert.Equal(engaged, !member.Target.Value.IsNone); + + // Once complied the same speed stays complied; once engaged it stays engaged. + Tick(.1f); + Assert.Equal(engaged, !member.Target.Value.IsNone); + } + [Fact] public void AGrudgeSkipsTheGrace() { @@ -199,13 +220,13 @@ public sealed class FlightTests : IDisposable [Fact] public void FollowClosesWhenFarAndOpensWhenNear() { - var far = Pilot(null); - var near = Pilot(null); + var far = Pilot(null, -200, 50); + var near = Pilot(null, -200, 50); var anchor = Track(); float TurnAfterFollowing(Ship ship, float3 anchorAt) { - anchor.Position = anchorAt; + anchor.Position = ship.Position + anchorAt; var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) { Task = new FollowTask { Anchor = anchor, Standoff = 100 } }; minion.Update(.1f); @@ -213,7 +234,8 @@ public sealed class FlightTests : IDisposable return ship.Turn; } - // The hull faces +z. Anchor 300 ahead: the desired velocity is dead ahead, no turn. Anchor 20 ahead: inside the + // The hull faces +z, and the pilots are off the origin so the anchor's offset from the ship counts, not its + // coordinates. 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); @@ -413,20 +435,22 @@ public sealed class FlightTests : IDisposable Assert.Same(track, member.Target.Value.Entity); } + // The member is off the origin and its nearer track is the one farther from the origin: a pick by coordinates alone + // takes the other. Kills: Flight's nearest pick reading the track's position without the member's. [Fact] public void AMemberKeepsItsTargetWhileThatTrackStaysEngaged() { - var member = Pilot(new Faction { Name = "Plain" }); - var first = Track(50); - var second = Track(200); + var member = Pilot(new Faction { Name = "Plain" }, 1000); + var first = Track(950); + var second = Track(100); 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); + first.Position = new float3(-500, 0, 0); + second.Position = new float3(990, 0, 0); Tick(.1f); Assert.Same(first, member.Target.Value.Entity); // kept, though the other is nearer now } @@ -434,21 +458,40 @@ public sealed class FlightTests : IDisposable [Fact] public void HoldersCloseOnTheNearestHeldTrackAndKeepItWhileItIsHeld() { - var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold.")); - var near = Track(50); - var far = Track(300); + var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold."), 1000); + var near = Track(950); + var far = Track(100); 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); + near.Position = new float3(-900, 0, 0); + far.Position = new float3(990, 0, 0); Tick(.1f); Assert.Same(near, ((FollowTask) _zone.Agents.Single().Task).Anchor); } + // The held anchor leaves the picture: the member re-anchors on what is still held. Kills: the anchor rewritten only + // when it is unchanged (`==` for `!=`), which leaves the member following a track that is gone. + [Fact] + public void AHeldAnchorThatLeavesThePictureIsReplacedByTheRemainingTrack() + { + var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold."), 1000); + var near = Track(950); + var far = Track(100); + See(member, near); + See(member, far); + Tick(.1f); + Assert.Same(near, ((FollowTask) _zone.Agents.Single().Task).Anchor); + + member.VisibleEnemies.Remove(near); + Tick(.1f); + + Assert.Same(far, ((FollowTask) _zone.Agents.Single().Task).Anchor); + } + [Fact] public void ATrackThatLeavesForgetsItsPhaseAndTheMemberResumesItsPatrol() { @@ -492,8 +535,8 @@ public sealed class FlightTests : IDisposable float Turn(float anchorX) { - var ship = Pilot(null); - anchor.Position = new float3(anchorX, 0, 0); + var ship = Pilot(null, -200, 50); + anchor.Position = ship.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); @@ -533,14 +576,32 @@ public sealed class FlightTests : IDisposable Assert.IsNotType(CurrentState(minion)); } - [Fact] - public void AMemberThatCannotSeeTheEngagedTrackClosesOnItsBearing() + public static System.Collections.Generic.IEnumerable UnseenGeometries() => + from distance in new[] { 90f, 900f } + from blindFirst in new[] { true, false } + from graced in new[] { false, true } + from right in new[] { true, false } + select new object[] { distance, blindFirst, graced, right }; + + // A member that cannot see the engaged track steers on it as a bearing, wherever it is, whoever was built first, with + // or without a hail first (the grace ends in a grudge), left or right of the member. Kills: the unseen rule holding + // the track only near, only for a non-first member, only with no grace, or only on one side. + [Theory] + [MemberData(nameof(UnseenGeometries))] + public void AMemberThatCannotSeeTheEngagedTrackClosesOnItsBearing(float distance, bool blindFirst, bool graced, bool right) { - var faction = WithDoctrine(EngageOn.Detection); - var sighted = Pilot(faction); - var blind = Pilot(faction, 10); - var track = Track(); + var faction = WithDoctrine(EngageOn.Detection, grace: graced ? 5 : 0, hail: "Hold."); + Ship blind, sighted; + if (blindFirst) { blind = Pilot(faction, 0, 0); sighted = Pilot(faction, 0, 400); } + else { sighted = Pilot(faction, 0, 400); blind = Pilot(faction, 0, 0); } + var track = Track(right ? distance : -distance); See(sighted, track); + if (graced) + { + Tick(.1f); // hailed, not yet engaged + sighted.SetIff(track, true); + } + Tick(.1f); Assert.Same(track, sighted.Target.Value.Entity); @@ -549,6 +610,57 @@ public sealed class FlightTests : IDisposable Assert.Same(track, task.Anchor); } + // The flight's engaged track outranks a hailed one a blind member does see, though that one is nearer. Kills: the + // anchor picked by distance alone, or kept once something outranks it. + [Fact] + public void ABlindMemberAnchorsOnTheEngagedTrackOverANearerHailingOne() + { + var faction = WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold."); + var blind = Pilot(faction, 0, 0); + var sighted = Pilot(faction, 0, 400); + var engaged = Track(900); + var hailing = Track(50); + See(sighted, engaged); + See(blind, hailing); + Tick(.1f); + var task = Assert.IsType(_zone.Agents.Single(a => a.Ship == blind).Task); + Assert.Same(hailing, task.Anchor); // nothing outranks it yet: the nearest held + + sighted.SetIff(engaged, true); + Tick(.1f); + + Assert.Same(engaged, sighted.Target.Value.Entity); + task = Assert.IsType(_zone.Agents.Single(a => a.Ship == blind).Task); + Assert.Same(engaged, task.Anchor); + } + + // Hail then engage is the production path of every graced doctrine: the member goes from FollowState into combat, + // and out of it again while the Task is a FollowTask on another held track. Kills: the combat interrupt covering + // the patrol sub-states only, or the exit reading a patrol Task only. + [Fact] + public void CombatIsEnteredFromFollowAndLeftWhileAnotherTrackIsHeld() + { + var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold.")); + var minion = (Minion) _zone.Agents.Single(); + var hot = Track(300); + var held = Track(-300); + See(member, hot); + See(member, held); + Tick(.1f); + UpdateUntil(minion, () => CurrentState(minion) is FollowState); + + member.SetIff(hot, true); + Tick(.1f); + Assert.Same(hot, member.Target.Value.Entity); + UpdateUntil(minion, () => CurrentState(minion) is CombatState); + + member.VisibleEnemies.Remove(hot); + Tick(.1f); + Assert.IsType(minion.Task); + UpdateUntil(minion, () => !(CurrentState(minion) is CombatState)); + UpdateUntil(minion, () => CurrentState(minion) is FollowState); + } + private Minion PatrollingMinion(Ship ship) { // Far from the (unresolved) circuit point, so MoveToOrbit does not hand back to patrol on arrival every tick. @@ -653,6 +765,35 @@ public sealed class FlightTests : IDisposable finally { File.Delete(path); } } + [Fact] + public void ADryRunOfTheCatalogCommandWritesNothing() + { + var path = TempCatalog(); + try + { + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + cache.Upsert(new VerseGrammar { Revision = 1 }); + cache.Upsert(new Faction { Name = "Zhestokost" }); + cache.FlushAsync().Wait(); + } + + var before = File.ReadAllBytes(path); + var output = new StringWriter(); + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + DoctrineCatalog.Run(cache, apply: false, output); + cache.FlushAsync().Wait(); + } + + Assert.Contains("Zhestokost: engage on", output.ToString()); + Assert.Equal(before, File.ReadAllBytes(path)); + using (var cache = AetheriaStores.Open(path)) + Assert.Null(cache.GetAll().Single().Doctrine); + } + finally { File.Delete(path); } + } + [Fact] public void TheCatalogCommandNamesAMissingFaction() { diff --git a/tools/AetherDb/DoctrineCatalog.cs b/tools/AetherDb/DoctrineCatalog.cs index 5b0413d62..16e83dee8 100644 --- a/tools/AetherDb/DoctrineCatalog.cs +++ b/tools/AetherDb/DoctrineCatalog.cs @@ -4,8 +4,8 @@ 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. +// Tuning is a starting point; the operator adjusts it after play. The Pirates record is on master (cut pirates-record); +// a faction with no Faction record is named and skipped, and apply is re-run after a cast faction's record lands. public static class DoctrineCatalog { private static readonly (string faction, FactionDoctrine doctrine)[] Table = @@ -39,7 +39,7 @@ public static class DoctrineCatalog 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)); + var faction = factions.FirstOrDefault(f => f.Name == name); if (faction == null) { output.WriteLine($"{name}: no Faction record, doctrine not written"); diff --git a/tools/AetherDb/VerseGrammarCommand.cs b/tools/AetherDb/VerseGrammarCommand.cs index e997b87fe..883fe840d 100644 --- a/tools/AetherDb/VerseGrammarCommand.cs +++ b/tools/AetherDb/VerseGrammarCommand.cs @@ -37,6 +37,8 @@ public static class VerseGrammarCommand Roles = new[] { Role("target", VerseReferentKind.Faction) } }, new VerseVerb { Name = "explore", RenderPath = VerseRenderPath.ShipAction, Description = "The actor surveys a region and charts what is there.", Roles = new[] { Role("region", VerseReferentKind.Place) } }, + new VerseVerb { Name = "follow", RenderPath = VerseRenderPath.ShipAction, Description = "The actor shadows a ship of the target faction at a standoff, as a flight does while it hails.", + Roles = new[] { Role("target", VerseReferentKind.Faction) } }, new VerseVerb { Name = "speak", RenderPath = VerseRenderPath.Conversation, Description = "The actor says something to whoever is in earshot. Shown as Aetheria dialogue: face to face when docked, by radio with the speaker's portrait over their ship when undocked.", Roles = new VerseRole[0] }, }; From 4fc225b1d5d2ac1a13376619a85e6ab77fdbd8df Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 20:54:47 +0200 Subject: [PATCH 16/35] VerseGrammarTests: the shipped catalog holds eight starter verbs; combat exit asserts the Target cleared Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/FlightTests.cs | 1 + tests/Aetheria.Shared.Tests/VerseGrammarTests.cs | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs index 896012c53..ce9c4d948 100644 --- a/tests/Aetheria.Shared.Tests/FlightTests.cs +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -656,6 +656,7 @@ public sealed class FlightTests : IDisposable member.VisibleEnemies.Remove(hot); Tick(.1f); + Assert.True(member.Target.Value.IsNone, "Target " + member.Target.Value.Entity); Assert.IsType(minion.Task); UpdateUntil(minion, () => !(CurrentState(minion) is CombatState)); UpdateUntil(minion, () => CurrentState(minion) is FollowState); diff --git a/tests/Aetheria.Shared.Tests/VerseGrammarTests.cs b/tests/Aetheria.Shared.Tests/VerseGrammarTests.cs index ec6af3701..9e911eb46 100644 --- a/tests/Aetheria.Shared.Tests/VerseGrammarTests.cs +++ b/tests/Aetheria.Shared.Tests/VerseGrammarTests.cs @@ -301,12 +301,12 @@ public sealed class VerseGrammarTests : IDisposable // The shipped catalog carries the command's output. [Fact] - public void The_shipped_catalog_holds_revision_one_and_the_seven_starter_verbs() + public void The_shipped_catalog_holds_revision_one_and_the_eight_starter_verbs() { using var cache = OpenScoped(Path.Combine(AetherDb.FindRoot(), "GameData", "Aetheria.cc"), true); Assert.Equal(1, cache.GetGlobal().Revision); var starter = VerseGrammarCommand.StarterVerbs(); - Assert.Equal(7, starter.Length); + Assert.Equal(8, starter.Length); var shipped = cache.GetAll().ToArray(); Assert.Equal(starter.Length, shipped.Length); foreach (var expected in starter) From 45ab0b9f237623c0000fc8e637520014ca9f56b7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 20:55:22 +0200 Subject: [PATCH 17/35] Aetheria.cc: master's catalog with doctrine-catalog and verse-grammar re-applied doctrine-catalog apply wrote four doctrines (Pirates included); verse-grammar apply added the follow verb (follow-up catalog-blob-merge-order). Co-Authored-By: Claude Opus 5.5 --- GameData/Aetheria.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index 321476801..6212791be 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ed0500b462cb0a1dc93377e009c818e3f97c9612fd8f6fd02e17bfd6ea433350 -size 12738595 +oid sha256:b03b8660b446baf8271b4b968089d4cae1629549a4247206e8e334d1e404dcff +size 12739054 From c387b137d14c7ecbadb3b44f52abca2ccae406bb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 20:56:05 +0200 Subject: [PATCH 18/35] FlightTests: combat exit diagnostics Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/FlightTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs index ce9c4d948..211e8a1f7 100644 --- a/tests/Aetheria.Shared.Tests/FlightTests.cs +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -656,7 +656,7 @@ public sealed class FlightTests : IDisposable member.VisibleEnemies.Remove(hot); Tick(.1f); - Assert.True(member.Target.Value.IsNone, "Target " + member.Target.Value.Entity); + Assert.True(member.Target.Value.IsNone, "Target is hot " + ReferenceEquals(member.Target.Value.Entity, hot) + ", held " + _zone.Flights.Single().PhaseOf(held) + ", hot " + _zone.Flights.Single().PhaseOf(hot) + ", visible " + member.VisibleEnemies.Count); Assert.IsType(minion.Task); UpdateUntil(minion, () => !(CurrentState(minion) is CombatState)); UpdateUntil(minion, () => CurrentState(minion) is FollowState); From 807533d26b1eb838419b057f18a3979f8a42e291 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 20:58:26 +0200 Subject: [PATCH 19/35] FlightTests: the combat-exit test removes every listing of the engaged track The grudge lists the track in VisibleEnemies a second time (the hostility refresh adds it again), so a single Remove left it visible. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/FlightTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs index 211e8a1f7..31b057535 100644 --- a/tests/Aetheria.Shared.Tests/FlightTests.cs +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -654,9 +654,10 @@ public sealed class FlightTests : IDisposable Assert.Same(hot, member.Target.Value.Entity); UpdateUntil(minion, () => CurrentState(minion) is CombatState); - member.VisibleEnemies.Remove(hot); + // The grudge listed it a second time (the hostility refresh adds it again), so every listing goes. + while (member.VisibleEnemies.Remove(hot)) { } Tick(.1f); - Assert.True(member.Target.Value.IsNone, "Target is hot " + ReferenceEquals(member.Target.Value.Entity, hot) + ", held " + _zone.Flights.Single().PhaseOf(held) + ", hot " + _zone.Flights.Single().PhaseOf(hot) + ", visible " + member.VisibleEnemies.Count); + Assert.True(member.Target.Value.IsNone); Assert.IsType(minion.Task); UpdateUntil(minion, () => !(CurrentState(minion) is CombatState)); UpdateUntil(minion, () => CurrentState(minion) is FollowState); From c30764e33e86e56946e6abcff7619051fea317b6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 22:05:02 +0200 Subject: [PATCH 20/35] faction-play-1 fixes: doctrine-catalog apply writes nothing on rerun; tests pin the unseen rule off-origin, the hail speaker in every build order, wire slot 17, exact-name match, scaled standoff, compliance axes, threshold boundaries, follow velocity Does not build-verified yet. Co-Authored-By: Claude Opus 5.5 --- .../FireControlPerWeaponTests.cs | 30 ++- tests/Aetheria.Shared.Tests/FlightTests.cs | 235 +++++++++++++++--- tools/AetherDb/DoctrineCatalog.cs | 15 ++ 3 files changed, 237 insertions(+), 43 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 8a9f5c8fd..1b8ba7dfa 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -331,15 +331,35 @@ public sealed class FireControlPerWeaponTests : IDisposable Assert.Equal(fires, r.Gun.Weapon.Firing); } + // The same threshold a hundredth either side of the shot's price. Kills: the turret's threshold loosened or + // tightened by a tenth or a hundredth. + [Theory] + [InlineData(.01f, false)] + [InlineData(-.01f, true)] + public void ATurretHoldsAShotJustUnderTheThresholdAndTakesOneJustOver(float thresholdAbovePrice, bool fires) + { + var probe = Build(accuracy: .5f, minHit: 0f, turret: true); + var price = FireControl.HitProbability(probe.Gun.Weapon, probe.Shooter, probe.Target); + var r = Build(accuracy: .5f, minHit: price + thresholdAbovePrice, turret: true); + Assert.Equal(price, FireControl.HitProbability(r.Gun.Weapon, r.Shooter, r.Target), 5); + + r.Zone.Update(1f); + r.Zone.Update(1f); + + Assert.Equal(fires, r.Gun.Weapon.Firing); + } + // A flight's members hold off at 1.1 x their longest gun when the doctrine authors no standoff (every authored - // doctrine leaves it 0), and at the authored distance otherwise. Guns of Range 50 and 100 on one ship. Kills: the + // doctrine leaves it 0), and at the authored distance otherwise. Guns of Range r and 2r on one ship, at two r and two authored values. Kills: the factor replaced by an offset or an affine rule, the // default zeroed, inverted, taken from the shortest gun, or the authored value ignored. [Theory] - [InlineData(0f, 110f)] - [InlineData(40f, 40f)] - public void AFlightHoldsOffAtTheDefaultOrAuthoredStandoff(float holdStandoff, float expected) + [InlineData(50f, 0f, 110f)] + [InlineData(100f, 0f, 220f)] + [InlineData(50f, 40f, 40f)] + [InlineData(50f, 250f, 250f)] + public void AFlightHoldsOffAtTheDefaultOrAuthoredStandoff(float range, float holdStandoff, float expected) { - var r = Build(range: 50f, decoy: true); + var r = Build(range: range, decoy: true); r.Shooter.SetIff(r.Target, null); if (!r.Shooter.VisibleEnemies.Contains(r.Target)) r.Shooter.VisibleEnemies.Add(r.Target); var settings = r.Shooter.ItemManager.GameplaySettings; diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs index 31b057535..4bbc45eae 100644 --- a/tests/Aetheria.Shared.Tests/FlightTests.cs +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -95,8 +95,8 @@ public sealed class FlightTests : IDisposable var track = Track(); See(a, track); See(b, track); - a.EntityInfoGathered[track] = .7f; - b.EntityInfoGathered[track] = .7f; + a.EntityInfoGathered[track] = MathF.BitDecrement(.8f); // just under the threshold + b.EntityInfoGathered[track] = MathF.BitDecrement(.8f); Tick(.1f); Assert.True(a.Target.Value.IsNone); @@ -136,29 +136,38 @@ public sealed class FlightTests : IDisposable Assert.True(member.Target.Value.IsNone); } - [Fact] - public void GraceHailsOnceFromTheNearestThenEngages() + public static System.Collections.Generic.IEnumerable BuildOrders() => + from order in new[] { "NTF", "NFT", "TNF", "TFN", "FNT", "FTN" } + select new object[] { order }; + + // The track is off the origin. N is the nearest member; T is nearer than N in x alone and F nearer in z alone, both + // farther in distance. Built in every order: the speaker is the nearest by distance, not first or last built, nor + // the nearest on one axis. Kills: the hail from the first or last member, or from the nearest by x or by z only. + [Theory] + [MemberData(nameof(BuildOrders))] + public void GraceHailsOnceFromTheNearestThenEngages(string order) { var faction = WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold."); - var far = Pilot(faction, 0); - var near = Pilot(faction, 90); // built second: the nearest, not the first built, speaks - var track = Track(100); - See(near, track); - See(far, track); + var (tx, tz) = (300f, -200f); + var spots = new System.Collections.Generic.Dictionary + { ['N'] = (tx + 30, tz + 40), ['T'] = (tx + 5, tz + 120), ['F'] = (tx - 200, tz + 10) }; + var ships = order.ToDictionary(c => c, c => Pilot(faction, spots[c].x, spots[c].z)); + var track = Track(tx, tz); + foreach (var ship in ships.Values) See(ship, 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.True(ships['N'].Messages.ContainsKey("Hold.")); + Assert.False(ships['T'].Messages.ContainsKey("Hold.")); + Assert.False(ships['F'].Messages.ContainsKey("Hold.")); + Assert.All(ships.Values, m => Assert.True(m.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); + Assert.All(ships.Values, m => Assert.True(m.Target.Value.IsNone)); Tick(.4f); - Assert.Same(track, near.Target.Value.Entity); - Assert.Same(track, far.Target.Value.Entity); + Assert.All(ships.Values, m => Assert.Same(track, m.Target.Value.Entity)); } [Fact] @@ -183,14 +192,20 @@ public sealed class FlightTests : IDisposable // Grace 3 against ComplySpeed 10: a track at 5 is slower than ComplySpeed (complied) though faster than the Grace // number, and one at 12 is faster than both. Kills: either comparison reading Grace for ComplySpeed. + // The speed is the length of the velocity: along x, along y, and on the diagonal at the same speed. Kills: either + // comparison reading one component. [Theory] - [InlineData(5f, false)] - [InlineData(12f, true)] - public void ComplianceIsJudgedOnComplySpeedAndNotOnGrace(float speed, bool engaged) + [InlineData(5f, false, 1f, 0f)] + [InlineData(12f, true, 1f, 0f)] + [InlineData(5f, false, 0f, 1f)] + [InlineData(12f, true, 0f, 1f)] + [InlineData(5f, false, .6f, .8f)] + [InlineData(12f, true, .6f, .8f)] + public void ComplianceIsJudgedOnComplySpeedAndNotOnGrace(float speed, bool engaged, float dx, float dy) { var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 3, hail: "Hold.", comply: 10)); var track = Track(); - track.Velocity = new float2(speed, 0); + track.Velocity = new float2(dx, dy) * speed; See(member, track); Tick(.5f); @@ -578,48 +593,54 @@ public sealed class FlightTests : IDisposable public static System.Collections.Generic.IEnumerable UnseenGeometries() => from distance in new[] { 90f, 900f } - from blindFirst in new[] { true, false } + from blindIndex in new[] { 0, 1, 2 } from graced in new[] { false, true } from right in new[] { true, false } - select new object[] { distance, blindFirst, graced, right }; + from blindAt in new[] { new[] { -300f, 250f }, new[] { 300f, -250f } } + select new object[] { distance, blindIndex, graced, right, blindAt[0], blindAt[1] }; - // A member that cannot see the engaged track steers on it as a bearing, wherever it is, whoever was built first, with - // or without a hail first (the grace ends in a grudge), left or right of the member. Kills: the unseen rule holding - // the track only near, only for a non-first member, only with no grace, or only on one side. + // A member that cannot see the engaged track steers on it as a bearing, wherever it is (off the origin, on both axes), + // at any index in the flight, with or without a hail first (the grace ends in a grudge), left or right of the member. + // Kills: the unseen rule holding the track only near, only for one member index, only with no grace, only on one + // side, or only for a member at the origin or below some coordinate. [Theory] [MemberData(nameof(UnseenGeometries))] - public void AMemberThatCannotSeeTheEngagedTrackClosesOnItsBearing(float distance, bool blindFirst, bool graced, bool right) + public void AMemberThatCannotSeeTheEngagedTrackClosesOnItsBearing(float distance, int blindIndex, bool graced, bool right, float x, float z) { var faction = WithDoctrine(EngageOn.Detection, grace: graced ? 5 : 0, hail: "Hold."); - Ship blind, sighted; - if (blindFirst) { blind = Pilot(faction, 0, 0); sighted = Pilot(faction, 0, 400); } - else { sighted = Pilot(faction, 0, 400); blind = Pilot(faction, 0, 0); } - var track = Track(right ? distance : -distance); - See(sighted, track); + var members = new Ship[3]; + var sightedOffsets = new[] { 400f, -450f }; + for (int i = 0, s = 0; i < 3; i++) + members[i] = i == blindIndex ? Pilot(faction, x, z) : Pilot(faction, x, z + sightedOffsets[s++]); + var blind = members[blindIndex]; + var sighted = members.Where(m => m != blind).ToArray(); + var track = Track(x + (right ? distance : -distance), z + 60); + foreach (var m in sighted) See(m, track); if (graced) { Tick(.1f); // hailed, not yet engaged - sighted.SetIff(track, true); + sighted[0].SetIff(track, true); } Tick(.1f); - Assert.Same(track, sighted.Target.Value.Entity); + Assert.All(sighted, m => Assert.Same(track, m.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); } - // The flight's engaged track outranks a hailed one a blind member does see, though that one is nearer. Kills: the - // anchor picked by distance alone, or kept once something outranks it. + // The flight's engaged track outranks a hailed one a blind member does see, though that one is nearer. Both sit + // either side of a blind member off the origin. Kills: the anchor picked by distance alone, or kept once something + // outranks it. [Fact] public void ABlindMemberAnchorsOnTheEngagedTrackOverANearerHailingOne() { var faction = WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold."); - var blind = Pilot(faction, 0, 0); - var sighted = Pilot(faction, 0, 400); - var engaged = Track(900); - var hailing = Track(50); + var blind = Pilot(faction, -300, 250); + var sighted = Pilot(faction, -300, 650); + var engaged = Track(600, 250); + var hailing = Track(-250, 250); See(sighted, engaged); See(blind, hailing); Tick(.1f); @@ -727,6 +748,144 @@ public sealed class FlightTests : IDisposable Assert.IsType(CurrentState(minion)); } + // A complied track that moves off at ComplySpeed or more is engaged, whichever way it moves: the diagonal's x + // component alone is under the speed. Kills: the Complied exit reading one component. + [Theory] + [InlineData(1f, 0f)] + [InlineData(0f, 1f)] + [InlineData(.6f, .8f)] + public void ACompliedTrackSpeedingOffOnAnyAxisIsEngaged(float dx, float dy) + { + 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(dx, dy) * 12f; + Tick(.1f); + + Assert.Same(track, member.Target.Value.Entity); + } + + // A track with no presence value is permitted: a Trespass doctrine leaves it alone. + [Fact] + public void TrespassLeavesATrackWithNoPresenceValueAlone() + { + var member = Pilot(WithDoctrine(EngageOn.Trespass)); + var track = Track(); + Assert.Null(track.PresencePermitted); + See(member, track); + + Tick(.1f); + + Assert.True(member.Target.Value.IsNone); + } + + // The follower matches the anchor's velocity: at the standoff the pull is zero, the desired velocity is the + // anchor's, and the ship turns toward it, either way. Kills: the anchor's velocity dropped from the desired one. + [Fact] + public void AFollowerAtItsStandoffTurnsToMatchTheAnchorsVelocity() + { + float TurnFor(float anchorVelocityX) + { + var ship = Pilot(null, -200, 50); + var anchor = Track(); + anchor.Position = ship.Position + new float3(0, 0, 100); + anchor.Velocity = new float2(anchorVelocityX, 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 east = TurnFor(100); + var west = TurnFor(-100); + + Assert.NotEqual(0f, east); + Assert.Equal(sign(east), -sign(west)); + } + + // Faction.Doctrine rides at wire slot 17; slot 16 is reserved for Relations. Kills: the key moved. + [Fact] + public void TheFactionDoctrineRidesAtWireSlot17() + { + var faction = new Faction { Name = "Armed", Doctrine = new FactionDoctrine { EngageOn = EngageOn.Trespass, Grace = 6f } }; + + var slots = MessagePack.MessagePackSerializer.Deserialize(MessagePack.MessagePackSerializer.Serialize(faction)); + + Assert.Equal(18, slots.Length); + Assert.Null(slots[16]); + var doctrine = Assert.IsType(slots[17]); + Assert.Equal(6f, Convert.ToSingle(doctrine[1])); + } + + // The catalog names a faction exactly: a prefix, a longer name or another case is another faction. Kills: the match + // widened to StartsWith, Contains or a case-insensitive compare. + [Fact] + public void TheCatalogMatchesFactionNamesExactly() + { + var path = TempCatalog(); + try + { + var near = new[] { "Zhestokost Prime", "zhestokost", "The Pirates", "Pirate", "Lucent Media " }; + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + cache.Upsert(new VerseGrammar { Revision = 1 }); + cache.Upsert(new Faction { Name = "Zhestokost" }); + foreach (var name in near) cache.Upsert(new Faction { Name = name }); + cache.FlushAsync().Wait(); + } + + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + DoctrineCatalog.Run(cache, apply: true, new StringWriter()); + cache.FlushAsync().Wait(); + } + + using (var cache = AetheriaStores.Open(path)) + { + var factions = cache.GetAll().ToArray(); + Assert.NotNull(factions.Single(f => f.Name == "Zhestokost").Doctrine); + Assert.All(factions.Where(f => near.Contains(f.Name)), f => Assert.Null(f.Doctrine)); + } + } + finally { File.Delete(path); } + } + + // A rerun that finds nothing to author writes nothing: the file is the same bytes. + [Fact] + public void ReapplyingTheCatalogLeavesTheBytesAlone() + { + var path = TempCatalog(); + try + { + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + cache.Upsert(new VerseGrammar { Revision = 1 }); + foreach (var name in new[] { "Zhestokost", "Lucent Media", "Aeronautics Unlimited", "Pirates" }) cache.Upsert(new Faction { Name = name }); + cache.FlushAsync().Wait(); + } + + void Apply() + { + using var cache = AetheriaStores.Open(path, catalogWritable: true); + DoctrineCatalog.Run(cache, apply: true, new StringWriter()); + cache.FlushAsync().Wait(); + } + + Apply(); + var first = File.ReadAllBytes(path); + System.Threading.Thread.Sleep(50); // a rewritten stamp would differ + Apply(); + + Assert.Equal(first, File.ReadAllBytes(path)); + } + finally { File.Delete(path); } + } + private static string TempCatalog() => Path.Combine(Path.GetTempPath(), "aetheria-doctrine-" + Guid.NewGuid().ToString("N") + ".cc"); [Fact] diff --git a/tools/AetherDb/DoctrineCatalog.cs b/tools/AetherDb/DoctrineCatalog.cs index 16e83dee8..c3a84ed3f 100644 --- a/tools/AetherDb/DoctrineCatalog.cs +++ b/tools/AetherDb/DoctrineCatalog.cs @@ -6,6 +6,8 @@ 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. The Pirates record is on master (cut pirates-record); // a faction with no Faction record is named and skipped, and apply is re-run after a cast faction's record lands. +// A faction that already has a doctrine is kept as it is (the operator tunes after play), so a rerun that finds +// nothing to author writes nothing. public static class DoctrineCatalog { private static readonly (string faction, FactionDoctrine doctrine)[] Table = @@ -46,6 +48,12 @@ public static class DoctrineCatalog continue; } + if (faction.Doctrine != null) + { + output.WriteLine($"{name}: already has a doctrine, kept"); + 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.##}"); @@ -59,6 +67,13 @@ public static class DoctrineCatalog return 0; } + if (changed.Count == 0) + { + output.WriteLine(" +Nothing to author."); + return 0; + } + cache.Commit(batch => { foreach (var faction in changed) batch.Upsert(typeof(Faction), faction, cache.RefOf(faction).Key); From 7518c0f3e8ea5cde40b9ab41fb47b9585e4a1e6e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 22:07:49 +0200 Subject: [PATCH 21/35] DoctrineCatalog: fix the nothing-to-author line (previous commit does not build) Co-Authored-By: Claude Opus 5.5 --- tools/AetherDb/DoctrineCatalog.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tools/AetherDb/DoctrineCatalog.cs b/tools/AetherDb/DoctrineCatalog.cs index c3a84ed3f..7dae56268 100644 --- a/tools/AetherDb/DoctrineCatalog.cs +++ b/tools/AetherDb/DoctrineCatalog.cs @@ -69,8 +69,7 @@ public static class DoctrineCatalog if (changed.Count == 0) { - output.WriteLine(" -Nothing to author."); + output.WriteLine("Nothing to author."); return 0; } From 1519aa8f44808c1f8344698530a0f3a2623c9198 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 22:24:33 +0200 Subject: [PATCH 22/35] Tests: consumable-ai fixtures let the flight target the prey (the flight is the only Target writer); standoff range, presence and wire-slot test fixes Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ConsumableAiTests.cs | 13 +++++++------ .../FireControlPerWeaponTests.cs | 2 +- tests/Aetheria.Shared.Tests/FlightTests.cs | 6 ++++-- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs index ca1081585..bf50ed661 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs @@ -177,10 +177,12 @@ public sealed class ConsumableAiTests : IDisposable zone.Agents.Single().Task = null; // the test zone has no orbits to patrol; the Minion and its CombatState are production's zone.Admit(scene.Prey, piloted: false); zone.Admit(scene.Locker, piloted: false); - zone.Update(0f); // brings the gear online + // The flight is the only writer of the agent's Target: the agent senses the prey and holds a grudge on it, so the + // flight engages it, and the Task stays null (an engaged member's Task is never rewritten). scene.Agent.SetIff(scene.Prey, true); scene.Agent.EntityInfoGathered[scene.Prey] = 1f; - scene.Agent.SetTarget(scene.Prey); + scene.Agent.VisibleEnemies.Add(scene.Prey); + zone.Update(0f); // brings the gear online scene.Locker.Position = float3(0, 0, -40); scene.Place(100f, 0f); zone.Update(.05f); @@ -474,20 +476,19 @@ public sealed class ConsumableAiTests : IDisposable Assert.Equal(1, s.Carried); // the other vent design is still in cargo } - // The vent reads only the agent's own paints: with its target gone from VisibleEnemies (still targeted, no - // longer sensed) and the locker unseen, it still vents. + // The vent reads only the agent's own paints: with the locker unseen, it still vents. (On this branch the flight + // writes the Target from the sensed picture, so a target gone from VisibleEnemies is no longer held: see the fork.) [Fact] public void AVentDoesNotNeedAnyEnemyInSight() { var s = Build(carryVent: true); s.AssertInCombat(); - s.Agent.VisibleEnemies.Clear(); Assert.Equal(s.Prey, s.Agent.Target.Value.Entity); Assert.DoesNotContain(s.Locker, s.Agent.VisibleEnemies); s.Tick((PaintMode.Track, .9f)); - Assert.Empty(s.Agent.VisibleEnemies); + Assert.DoesNotContain(s.Locker, s.Agent.VisibleEnemies); Assert.True(s.Active(s.Vent)); } } diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 1b8ba7dfa..70b7d729a 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -354,7 +354,7 @@ public sealed class FireControlPerWeaponTests : IDisposable // default zeroed, inverted, taken from the shortest gun, or the authored value ignored. [Theory] [InlineData(50f, 0f, 110f)] - [InlineData(100f, 0f, 220f)] + [InlineData(200f, 0f, 220f)] [InlineData(50f, 40f, 40f)] [InlineData(50f, 250f, 250f)] public void AFlightHoldsOffAtTheDefaultOrAuthoredStandoff(float range, float holdStandoff, float expected) diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs index 4bbc45eae..5c9a2cd19 100644 --- a/tests/Aetheria.Shared.Tests/FlightTests.cs +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Reflection; using CultMath; using GameCult.Caching; +using GameCult.Caching.MessagePack; using UniRx; using Xunit; using static CultMath.math; @@ -775,7 +776,7 @@ public sealed class FlightTests : IDisposable { var member = Pilot(WithDoctrine(EngageOn.Trespass)); var track = Track(); - Assert.Null(track.PresencePermitted); + track.PresencePermitted = null; See(member, track); Tick(.1f); @@ -814,7 +815,8 @@ public sealed class FlightTests : IDisposable { var faction = new Faction { Name = "Armed", Doctrine = new FactionDoctrine { EngageOn = EngageOn.Trespass, Grace = 6f } }; - var slots = MessagePack.MessagePackSerializer.Deserialize(MessagePack.MessagePackSerializer.Serialize(faction)); + var slots = MessagePack.MessagePackSerializer.Deserialize(MessagePack.MessagePackSerializer.Serialize(faction, + CultDocumentMessagePackSerialization.OptionsFor(typeof(Faction).Assembly))); Assert.Equal(18, slots.Length); Assert.Null(slots[16]); From ff346483717665cb1261aacc9b97feb597b6ca09 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 22:42:46 +0200 Subject: [PATCH 23/35] ConsumableAiTests: the refit re-admits the agent as a pilot (Zone.Remove drops the agent on this branch) Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ConsumableAiTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs index bf50ed661..d6c3adbf6 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs @@ -345,7 +345,8 @@ public sealed class ConsumableAiTests : IDisposable Assert.True(s.Zone.Remove(s.Agent)); s.Agent.Deactivate(); fit(s.Agent); - s.Zone.Admit(s.Agent, piloted: false); // back in, the agent's Minion keeps its CombatState + s.Zone.Admit(s.Agent, piloted: true); // back in as a pilot: the zone gives it a new Minion and CombatState + s.Zone.Agents.Single().Task = null; // no orbits to patrol in the test zone s.Place(30f, 0f); s.Zone.Update(.05f); s.Zone.Update(.05f); From afc570affdfc1c837561a4a0b0fb61256ceed001 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 22:52:29 +0200 Subject: [PATCH 24/35] ConsumableAiTests: the refitted agent senses the prey again Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ConsumableAiTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs index d6c3adbf6..bbaf2ec7a 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs @@ -347,6 +347,10 @@ public sealed class ConsumableAiTests : IDisposable fit(s.Agent); s.Zone.Admit(s.Agent, piloted: true); // back in as a pilot: the zone gives it a new Minion and CombatState s.Zone.Agents.Single().Task = null; // no orbits to patrol in the test zone + // Deactivation cleared its senses: it senses the prey again, as the sensors would after the jump in. + s.Agent.SetIff(s.Prey, true); + s.Agent.EntityInfoGathered[s.Prey] = 1f; + if (!s.Agent.VisibleEnemies.Contains(s.Prey)) s.Agent.VisibleEnemies.Add(s.Prey); s.Place(30f, 0f); s.Zone.Update(.05f); s.Zone.Update(.05f); From 9315a0019d5d22eeda1837bf1ecdadb458b80ae1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 22:57:44 +0200 Subject: [PATCH 25/35] FireControlPerWeaponTests: the turret threshold is pinned half a hundredth either side of the price Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 70b7d729a..21cde60cb 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -331,11 +331,11 @@ public sealed class FireControlPerWeaponTests : IDisposable Assert.Equal(fires, r.Gun.Weapon.Firing); } - // The same threshold a hundredth either side of the shot's price. Kills: the turret's threshold loosened or + // The same threshold half a hundredth either side of the shot's price. Kills: the turret's threshold loosened or // tightened by a tenth or a hundredth. [Theory] - [InlineData(.01f, false)] - [InlineData(-.01f, true)] + [InlineData(.005f, false)] + [InlineData(-.005f, true)] public void ATurretHoldsAShotJustUnderTheThresholdAndTakesOneJustOver(float thresholdAbovePrice, bool fires) { var probe = Build(accuracy: .5f, minHit: 0f, turret: true); From 0e40a665926fe80b7efd234121943d1d668cc447 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 00:21:40 +0200 Subject: [PATCH 26/35] Vent on a Track paint in every agent state: Agent.Update owns the vent, CombatState keeps the overdrive Ruling vent-in-any-state. The vent decision (own Track paint above .5, one of a kind through TryActivateConsumable) moves out of CombatState into Agent.Update, which every state runs; CombatState's overdrive asks Agent.TrackAlarm so a venting tick still does not overdrive. ConsumableAiTests: AVentDoesNotNeedAnyEnemyInSight is master's premise again (empty VisibleEnemies), a patrolling agent has its own test, and the fixture zone has one orbit so an unengaged pilot patrols it. Not built or run yet. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Agents/Agent.cs | 23 +++++++++++ .../ServerShared/Agents/States/Combat.cs | 20 ++-------- .../ConsumableAiTests.cs | 40 ++++++++++++++----- 3 files changed, 58 insertions(+), 25 deletions(-) diff --git a/Assets/Scripts/ServerShared/Agents/Agent.cs b/Assets/Scripts/ServerShared/Agents/Agent.cs index b1900a3f8..c906ce33b 100644 --- a/Assets/Scripts/ServerShared/Agents/Agent.cs +++ b/Assets/Scripts/ServerShared/Agents/Agent.cs @@ -15,6 +15,9 @@ public class Agent { private const float FORWARD_DELTA_THRESHOLD = 20; private const float THRUST_DELTA_THRESHOLD = 1; + // The Strength of one Track paint in this ship's own IncomingPaints: a fire-control emitter tracking this + // that hard (rulings lock-warning-strength, sensor-stat-set item 4, launcher-lock-solution-quality). + private const float LockAlarm = .5f; protected BaseState _rootState; private BaseState _currentState; private VelocityLimit _velocityLimit; @@ -52,6 +55,7 @@ public class Agent // The pilot tick writes Turn every tick, in every state: a state that pilots overwrites this zero with its // demand, and one that does not leaves the hull holding course instead of inheriting the last state's rate. Ship.Turn = 0; + VentOnTrack(); _currentState.Update(delta); foreach (var transition in _currentState.Transitions) if (transition.Condition()) @@ -61,6 +65,25 @@ public class Agent } } + // The AI plays the player's rule on the player's information: vent when one Track paint on this ship passes half. It + // reads only this ship's own IncomingPaints, so it runs in every state (ruling vent-in-any-state), and a ship + // carrying no vent does nothing. + private void VentOnTrack() + { + if (TrackAlarm) Use(b => b is VapourDumpData); + } + + // One Track paint on this ship passes the alarm: the ship vents, and does not overdrive on the same tick. + public bool TrackAlarm => Ship.IncomingPaints.Any(p => p.Mode == PaintMode.Track && p.Strength > LockAlarm); + + // Through TryActivateConsumable, one of a kind at a time: a Stackable design would otherwise be drained once per tick. + public void Use(Predicate kind) + { + if (Ship.HasActiveConsumable(kind)) return; + var consumable = Ship.FirstCarriedConsumable(kind); + if (consumable != null) Ship.TryActivateConsumable(consumable); + } + public void Accelerate(float2 targetVelocity, bool noTurn = false) { var deltaV = targetVelocity - Ship.Velocity; diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index 5c7518117..8ee3fa7a5 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -155,25 +155,13 @@ public class CombatState : BaseState // Fire charged guns! } - // The AI plays the player's rule on the player's information: vent when one Track paint on this ship passes half, - // otherwise overdrive to close a distant target ahead. It reads only this ship's own IncomingPaints. Both go - // through TryActivateConsumable, and a ship carrying neither does nothing. + // Overdrive to close a distant target ahead, through the same consumable gate as the vent (Agent.VentOnTrack). private void UseConsumables(float3 toTarget, float targetDistance) { var ship = _agent.Ship; - if (ship.IncomingPaints.Any(p => p.Mode == PaintMode.Track && p.Strength > LockAlarm)) - Use(b => b is VapourDumpData); - else if (targetDistance > OverdriveRangeFactor * _optimumRange && - dot(normalize(ship.Direction), normalize(toTarget.xz)) > cos(radians(OverdriveArcDegrees))) - Use(b => b is ThrottleLockData); - } - - // One of a kind at a time: a Stackable design would otherwise be drained once per tick. - private void Use(Predicate kind) - { - if (_agent.Ship.HasActiveConsumable(kind)) return; - var consumable = _agent.Ship.FirstCarriedConsumable(kind); - if (consumable != null) _agent.Ship.TryActivateConsumable(consumable); + if (!_agent.TrackAlarm && targetDistance > OverdriveRangeFactor * _optimumRange && + dot(normalize(ship.Direction), normalize(toTarget.xz)) > cos(radians(OverdriveArcDegrees))) + _agent.Use(b => b is ThrottleLockData); } private void SampleDps() diff --git a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs index bbaf2ec7a..46439667b 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs @@ -12,8 +12,8 @@ using float3 = CultMath.float3; using int2 = CultMath.int2; // AI pilots use consumables (aetheria-release cut consumable-ai r3; rulings sensor-stat-set item 4, -// lock-warning-strength, launcher-lock-solution-quality): CombatState vents when ONE Track paint in the ship's own -// IncomingPaints passes .5 and otherwise overdrives to close a target farther than twice its optimum range while +// lock-warning-strength, launcher-lock-solution-quality, vent-in-any-state): the agent vents, in every state, when ONE +// Track paint in the ship's own IncomingPaints passes .5; CombatState otherwise overdrives to close a target farther than twice its optimum range while // heading within 15 degrees of it. It reads only its own ship's senses. A consumable is chosen by what its behaviours // are, never by name, so the vent design is named "Overdrive" and the overdrive design "Coolant". // @@ -150,7 +150,10 @@ public sealed class ConsumableAiTests : IDisposable var maker = cache.RefOf(cache.GetByName("Maker")); EquippableItem Make(string name) where T : CraftedItemData => (EquippableItem) items.CreateInstance(items.CreateLot(cache.GetByName(name), maker, .5f)); - var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + // One fixed orbit far from the fight: a pilot that is not engaged patrols it, as production's would. + var pack = new ZonePack(); + pack.Orbits.Add(cache.Upsert(new OrbitData { FixedPosition = float2(0, 5000) })); + var zone = new Zone(items, new PlanetSettings(), pack, new GalaxyZone { Name = "Test Zone", Owner = null }, null); Ship NewShip(string gear) { var ship = new Ship(items, zone, Make("Skiff"), new EntitySettings()) @@ -174,11 +177,10 @@ public sealed class ConsumableAiTests : IDisposable // The agent joins as the zone admits a piloted ship (its Minion and CombatState come from production); the // locker is a gun ship nobody pilots. zone.Admit(scene.Agent, piloted: true); - zone.Agents.Single().Task = null; // the test zone has no orbits to patrol; the Minion and its CombatState are production's zone.Admit(scene.Prey, piloted: false); zone.Admit(scene.Locker, piloted: false); // The flight is the only writer of the agent's Target: the agent senses the prey and holds a grudge on it, so the - // flight engages it, and the Task stays null (an engaged member's Task is never rewritten). + // flight engages it. scene.Agent.SetIff(scene.Prey, true); scene.Agent.EntityInfoGathered[scene.Prey] = 1f; scene.Agent.VisibleEnemies.Add(scene.Prey); @@ -346,7 +348,6 @@ public sealed class ConsumableAiTests : IDisposable s.Agent.Deactivate(); fit(s.Agent); s.Zone.Admit(s.Agent, piloted: true); // back in as a pilot: the zone gives it a new Minion and CombatState - s.Zone.Agents.Single().Task = null; // no orbits to patrol in the test zone // Deactivation cleared its senses: it senses the prey again, as the sensors would after the jump in. s.Agent.SetIff(s.Prey, true); s.Agent.EntityInfoGathered[s.Prey] = 1f; @@ -481,19 +482,40 @@ public sealed class ConsumableAiTests : IDisposable Assert.Equal(1, s.Carried); // the other vent design is still in cargo } - // The vent reads only the agent's own paints: with the locker unseen, it still vents. (On this branch the flight - // writes the Target from the sensed picture, so a target gone from VisibleEnemies is no longer held: see the fork.) + // The vent reads only the agent's own paints: with its target gone from VisibleEnemies (still targeted, no + // longer sensed) and the locker unseen, it still vents. [Fact] public void AVentDoesNotNeedAnyEnemyInSight() { var s = Build(carryVent: true); s.AssertInCombat(); + s.Agent.VisibleEnemies.Clear(); Assert.Equal(s.Prey, s.Agent.Target.Value.Entity); Assert.DoesNotContain(s.Locker, s.Agent.VisibleEnemies); s.Tick((PaintMode.Track, .9f)); - Assert.DoesNotContain(s.Locker, s.Agent.VisibleEnemies); + Assert.Empty(s.Agent.VisibleEnemies); Assert.True(s.Active(s.Vent)); } + + // A patrolling agent has no target and senses no enemy, and it vents all the same (ruling vent-in-any-state). + [Fact] + public void APatrollingAgentVentsToo() + { + var s = Build(carryVent: true); + s.Agent.VisibleEnemies.Clear(); + s.Zone.Update(.05f); + s.Zone.Update(.05f); + Assert.False(s.State is CombatState, $"the agent is in {s.State?.GetType().Name}"); + Assert.True(s.Agent.Target.Value.IsNone); + Assert.IsType(s.Zone.Agents.Single().Task); + + s.Tick((PaintMode.Track, .3f)); + Assert.False(s.Active(s.Vent)); + s.Tick((PaintMode.Track, .9f)); + + Assert.True(s.Active(s.Vent)); + Assert.Equal(0, s.Carried); + } } From 2bb08a91eec818a05a0026a80188c7822105bf3d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 00:21:43 +0200 Subject: [PATCH 27/35] Flight: a track any member made friendly is out of the picture; pins for the s4 findings The flight builds the picture, so it is where per-member friendly overrides meet; the track is dropped there. New pins: a tuned doctrine survives doctrine-catalog apply, the follower's velocity match and pull at scale, compliance on a diagonal, the hail speaker and engaged pick off-axis. Not built or run yet. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Agents/Flight.cs | 5 +- tests/Aetheria.Shared.Tests/FlightTests.cs | 166 ++++++++++++++++--- 2 files changed, 145 insertions(+), 26 deletions(-) diff --git a/Assets/Scripts/ServerShared/Agents/Flight.cs b/Assets/Scripts/ServerShared/Agents/Flight.cs index ab3550b2b..0b77e9aad 100644 --- a/Assets/Scripts/ServerShared/Agents/Flight.cs +++ b/Assets/Scripts/ServerShared/Agents/Flight.cs @@ -60,7 +60,10 @@ public class Flight public void Update(float dt) { _time += dt; - var picture = _members.SelectMany(m => m.Agent.Ship.VisibleEnemies).Distinct().ToList(); + // A track any member has made friendly (a truce override) is not the flight's to engage or follow, though a flightmate + // still senses it as hostile: the overrides are per member, so the picture is where they meet. + var picture = _members.SelectMany(m => m.Agent.Ship.VisibleEnemies).Distinct() + .Where(e => !_members.Any(m => m.Agent.Ship.IffOverride(e) == false)).ToList(); foreach (var gone in _tracks.Keys.Where(t => !picture.Contains(t)).ToList()) _tracks.Remove(gone); foreach (var entity in picture) diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs index 5c9a2cd19..e1d5e1ba9 100644 --- a/tests/Aetheria.Shared.Tests/FlightTests.cs +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -138,12 +138,14 @@ public sealed class FlightTests : IDisposable } public static System.Collections.Generic.IEnumerable BuildOrders() => - from order in new[] { "NTF", "NFT", "TNF", "TFN", "FNT", "FTN" } + from order in new[] { "NTFM", "MFTN", "TMNF", "FNMT", "NMTF", "TFMN" } select new object[] { order }; - // The track is off the origin. N is the nearest member; T is nearer than N in x alone and F nearer in z alone, both - // farther in distance. Built in every order: the speaker is the nearest by distance, not first or last built, nor - // the nearest on one axis. Kills: the hail from the first or last member, or from the nearest by x or by z only. + // The track is off the origin. N is the nearest member by distance, on the diagonal; T is nearer than N in x alone, F + // nearer in z alone, and M nearest by the sum of the axis distances (60 against N's 80), all farther in distance. + // Built in several orders: the speaker is the nearest by distance, not first or last built, nor the nearest on one + // axis or by the sum of components. Kills: the hail from the first or last member, or from the nearest by x, by z + // or by the sum of components. [Theory] [MemberData(nameof(BuildOrders))] public void GraceHailsOnceFromTheNearestThenEngages(string order) @@ -151,7 +153,7 @@ public sealed class FlightTests : IDisposable var faction = WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold."); var (tx, tz) = (300f, -200f); var spots = new System.Collections.Generic.Dictionary - { ['N'] = (tx + 30, tz + 40), ['T'] = (tx + 5, tz + 120), ['F'] = (tx - 200, tz + 10) }; + { ['N'] = (tx + 40, tz + 40), ['T'] = (tx + 5, tz + 120), ['F'] = (tx - 200, tz + 10), ['M'] = (tx + 60, tz) }; var ships = order.ToDictionary(c => c, c => Pilot(faction, spots[c].x, spots[c].z)); var track = Track(tx, tz); foreach (var ship in ships.Values) See(ship, track); @@ -161,6 +163,7 @@ public sealed class FlightTests : IDisposable Assert.True(ships['N'].Messages.ContainsKey("Hold.")); Assert.False(ships['T'].Messages.ContainsKey("Hold.")); Assert.False(ships['F'].Messages.ContainsKey("Hold.")); + Assert.False(ships['M'].Messages.ContainsKey("Hold.")); Assert.All(ships.Values, m => Assert.True(m.Target.Value.IsNone)); Assert.All(_zone.Agents, a => Assert.IsType(a.Task)); @@ -202,6 +205,8 @@ public sealed class FlightTests : IDisposable [InlineData(12f, true, 0f, 1f)] [InlineData(5f, false, .6f, .8f)] [InlineData(12f, true, .6f, .8f)] + [InlineData(8f, false, .7071f, .7071f)] // 11.3 by the sum of components, 8 by length + [InlineData(12f, true, .7071f, .7071f)] // 8.5 by the larger component, 12 by length public void ComplianceIsJudgedOnComplySpeedAndNotOnGrace(float speed, bool engaged, float dx, float dy) { var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 3, hail: "Hold.", comply: 10)); @@ -379,6 +384,28 @@ public sealed class FlightTests : IDisposable Assert.Equal(_items.GameplaySettings.AgentMinHitProbability, combatant.MinHitProbability); } + // The picture is the union of the members' senses while overrides are per member: a track one member has made + // friendly (a truce) is out of the flight's picture though a flightmate senses it as hostile. Kills: the friendly + // override ignored when the picture is built. + [Theory] + [InlineData(EngageOn.Detection)] + [InlineData(EngageOn.Provoked)] + public void ATrackAMemberHasMadeFriendlyIsNotEngagedByTheFlight(EngageOn on) + { + var faction = WithDoctrine(on); + var friendly = Pilot(faction); + var sees = Pilot(faction, 10); + var track = Track(); + friendly.SetIff(track, false); + See(sees, track); + + Tick(.1f); + + Assert.True(sees.Target.Value.IsNone); + Assert.True(friendly.Target.Value.IsNone); + Assert.Equal(Flight.Phase.None, _zone.Flights.Single(f => f.Contains(sees)).PhaseOf(track)); + } + [Fact] public void AnyMembersGrudgeEngagesTheWholeFlight() { @@ -471,6 +498,25 @@ public sealed class FlightTests : IDisposable Assert.Same(first, member.Target.Value.Entity); // kept, though the other is nearer now } + // Three engaged tracks, each nearest by a different measure from the member: A by the sum of components (100), C by + // length (99.2), B by x alone (60). The member picks the nearest by length. Kills: the pick by the sum of components + // or by x alone. + [Fact] + public void AMemberPicksTheNearestEngagedTrackByLength() + { + var member = Pilot(new Faction { Name = "Plain" }, 1000, 500); + var a = Track(1100, 500); + var b = Track(1060, 590); + var c = Track(1075, 565); + See(member, a); + See(member, b); + See(member, c); + + Tick(.1f); + + Assert.Same(c, member.Target.Value.Entity); + } + [Fact] public void HoldersCloseOnTheNearestHeldTrackAndKeepItWhileItIsHeld() { @@ -784,29 +830,52 @@ public sealed class FlightTests : IDisposable Assert.True(member.Target.Value.IsNone); } - // The follower matches the anchor's velocity: at the standoff the pull is zero, the desired velocity is the - // anchor's, and the ship turns toward it, either way. Kills: the anchor's velocity dropped from the desired one. - [Fact] - public void AFollowerAtItsStandoffTurnsToMatchTheAnchorsVelocity() + // A follower at a distance from an anchor; the hull faces across the line to the anchor, so a demanded velocity + // along that line needs a turn. Returns the pilot's turn and thrust after two updates, and its top speed. + private (float turn, float2 thrust, float topSpeed) Follow(float2 anchorVelocity, float distance, float2 facing) { - float TurnFor(float anchorVelocityX) - { - var ship = Pilot(null, -200, 50); - var anchor = Track(); - anchor.Position = ship.Position + new float3(0, 0, 100); - anchor.Velocity = new float2(anchorVelocityX, 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 ship = Pilot(null, -200, 50); + ship.Direction = facing; + var anchor = Track(); + anchor.Position = ship.Position + new float3(0, 0, distance); + anchor.Velocity = anchorVelocity; + 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, ship.MovementDirection, minion.TopSpeed); + } - var east = TurnFor(100); - var west = TurnFor(-100); + // At the standoff the pull is zero and the demanded velocity is the anchor's alone. Accelerate turns the hull only + // for a velocity error above 20 and thrusts all ways above 1, so the anchor's velocity of 30 turns the hull on + // either axis and one of 15 does not (it thrusts). Kills: the anchor's velocity dropped, halved, doubled, or + // reduced to one component. + [Theory] + [InlineData(30f, 0f, 0f, 1f, true)] + [InlineData(0f, 30f, 1f, 0f, true)] + [InlineData(-30f, 0f, 0f, 1f, true)] + [InlineData(15f, 0f, 0f, 1f, false)] + [InlineData(0f, 15f, 1f, 0f, false)] + public void AFollowerAtItsStandoffMatchesTheAnchorsVelocityAtFullScale(float vx, float vy, float fx, float fy, bool turns) + { + var (turn, thrust, _) = Follow(new float2(vx, vy), 100, new float2(fx, fy)); - Assert.NotEqual(0f, east); - Assert.Equal(sign(east), -sign(west)); + Assert.Equal(turns, turn != 0f); + Assert.NotEqual(float2.zero, thrust); + } + + // The pull toward a stationary anchor is the error over the standoff, as a fraction of the pilot's top speed: a + // pull of 30 turns the hull and one of 15 does not. Kills: the pull halved or doubled. + [Theory] + [InlineData(30f, true)] + [InlineData(15f, false)] + public void AFollowersPullIsTheErrorOverTheStandoffAtTopSpeed(float wantedPull, bool turns) + { + var top = Follow(float2.zero, 100, new float2(1, 0)).topSpeed; + Assert.True(top >= 60f, "the fixture needs a top speed above the pull it asks for"); + var (turn, _, _) = Follow(float2.zero, 100 + 100 * wantedPull / top, new float2(1, 0)); + + Assert.Equal(turns, turn != 0f); } // Faction.Doctrine rides at wire slot 17; slot 16 is reserved for Relations. Kills: the key moved. @@ -824,6 +893,53 @@ public sealed class FlightTests : IDisposable Assert.Equal(6f, Convert.ToSingle(doctrine[1])); } + // A cast faction that already has a doctrine keeps it, whatever it holds: one that differs in its engagement and one + // that differs only below it. Kills: the keep tested on the engagement alone, or on any one field. + [Fact] + public void TheCatalogKeepsATunedDoctrine() + { + var path = TempCatalog(); + FactionDoctrine Tuned(EngageOn on) => new FactionDoctrine + { + EngageOn = on, Grace = 99f, Hail = "Mine.", ComplySpeed = 3f, + Combatant = new RoleDoctrine { RangeExponent = .7f, MinHitProbability = .9f, HoldStandoff = 5f } + }; + try + { + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + cache.Upsert(new VerseGrammar { Revision = 1 }); + cache.Upsert(new Faction { Name = "Zhestokost", Doctrine = Tuned(EngageOn.Never) }); + cache.Upsert(new Faction { Name = "Lucent Media", Doctrine = Tuned(EngageOn.Detection) }); + cache.FlushAsync().Wait(); + } + + var output = new StringWriter(); + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + DoctrineCatalog.Run(cache, apply: true, output); + cache.FlushAsync().Wait(); + } + + using (var cache = AetheriaStores.Open(path)) + { + foreach (var (name, on) in new[] { ("Zhestokost", EngageOn.Never), ("Lucent Media", EngageOn.Detection) }) + { + var kept = cache.GetAll().Single(f => f.Name == name).Doctrine; + Assert.Equal(on, kept.EngageOn); + Assert.Equal(99f, kept.Grace); + Assert.Equal("Mine.", kept.Hail); + Assert.Equal(3f, kept.ComplySpeed); + Assert.Equal(.7f, kept.Combatant.RangeExponent); + Assert.Equal(.9f, kept.Combatant.MinHitProbability); + Assert.Equal(5f, kept.Combatant.HoldStandoff); + Assert.Contains($"{name}: already has a doctrine, kept", output.ToString()); + } + } + } + finally { File.Delete(path); } + } + // The catalog names a faction exactly: a prefix, a longer name or another case is another faction. Kills: the match // widened to StartsWith, Contains or a case-insensitive compare. [Fact] From b08f7d80ae66994a95f2447f3689c8de34718f78 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 00:23:01 +0200 Subject: [PATCH 28/35] FlightTests: the friendly-override pin builds in both orders; compile fixes for the s4 pins Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/FlightTests.cs | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs index e1d5e1ba9..21b65ad41 100644 --- a/tests/Aetheria.Shared.Tests/FlightTests.cs +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -388,13 +388,16 @@ public sealed class FlightTests : IDisposable // friendly (a truce) is out of the flight's picture though a flightmate senses it as hostile. Kills: the friendly // override ignored when the picture is built. [Theory] - [InlineData(EngageOn.Detection)] - [InlineData(EngageOn.Provoked)] - public void ATrackAMemberHasMadeFriendlyIsNotEngagedByTheFlight(EngageOn on) + [InlineData(EngageOn.Detection, true)] + [InlineData(EngageOn.Detection, false)] + [InlineData(EngageOn.Provoked, true)] + [InlineData(EngageOn.Provoked, false)] + public void ATrackAMemberHasMadeFriendlyIsNotEngagedByTheFlight(EngageOn on, bool friendlyBuiltFirst) { var faction = WithDoctrine(on); - var friendly = Pilot(faction); - var sees = Pilot(faction, 10); + Ship friendly, sees; + if (friendlyBuiltFirst) { friendly = Pilot(faction); sees = Pilot(faction, 10); } + else { sees = Pilot(faction, 10); friendly = Pilot(faction); } var track = Track(); friendly.SetIff(track, false); See(sees, track); @@ -403,7 +406,7 @@ public sealed class FlightTests : IDisposable Assert.True(sees.Target.Value.IsNone); Assert.True(friendly.Target.Value.IsNone); - Assert.Equal(Flight.Phase.None, _zone.Flights.Single(f => f.Contains(sees)).PhaseOf(track)); + Assert.Equal(Flight.Phase.None, _zone.Flights.Single(f => f.Contains(_zone.Agents.Single(a => a.Ship == sees))).PhaseOf(track)); } [Fact] @@ -861,7 +864,7 @@ public sealed class FlightTests : IDisposable var (turn, thrust, _) = Follow(new float2(vx, vy), 100, new float2(fx, fy)); Assert.Equal(turns, turn != 0f); - Assert.NotEqual(float2.zero, thrust); + Assert.NotEqual(new float2(0, 0), thrust); } // The pull toward a stationary anchor is the error over the standoff, as a fraction of the pilot's top speed: a @@ -871,9 +874,9 @@ public sealed class FlightTests : IDisposable [InlineData(15f, false)] public void AFollowersPullIsTheErrorOverTheStandoffAtTopSpeed(float wantedPull, bool turns) { - var top = Follow(float2.zero, 100, new float2(1, 0)).topSpeed; + var top = Follow(new float2(0, 0), 100, new float2(1, 0)).topSpeed; Assert.True(top >= 60f, "the fixture needs a top speed above the pull it asks for"); - var (turn, _, _) = Follow(float2.zero, 100 + 100 * wantedPull / top, new float2(1, 0)); + var (turn, _, _) = Follow(new float2(0, 0), 100 + 100 * wantedPull / top, new float2(1, 0)); Assert.Equal(turns, turn != 0f); } From 6f31375def78a51cb82006c5a3513835e112761f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 00:40:59 +0200 Subject: [PATCH 29/35] Flight: a member's friendly override keeps it from following a flightmate's track; consumable-ai fixture orbit built on the zone The union picture stays the flight's; the per-member override is read where the member chooses its bearing. A first version dropped the track from the whole picture and silenced a hostile unaffiliated flightmate (SmokeRoundsStepped AiFusedDiscipline). Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Agents/Flight.cs | 8 +++----- tests/Aetheria.Shared.Tests/ConsumableAiTests.cs | 7 +++---- tests/Aetheria.Shared.Tests/FlightTests.cs | 15 ++++++++------- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/Assets/Scripts/ServerShared/Agents/Flight.cs b/Assets/Scripts/ServerShared/Agents/Flight.cs index 0b77e9aad..1fe54844b 100644 --- a/Assets/Scripts/ServerShared/Agents/Flight.cs +++ b/Assets/Scripts/ServerShared/Agents/Flight.cs @@ -60,10 +60,7 @@ public class Flight public void Update(float dt) { _time += dt; - // A track any member has made friendly (a truce override) is not the flight's to engage or follow, though a flightmate - // still senses it as hostile: the overrides are per member, so the picture is where they meet. - var picture = _members.SelectMany(m => m.Agent.Ship.VisibleEnemies).Distinct() - .Where(e => !_members.Any(m => m.Agent.Ship.IffOverride(e) == false)).ToList(); + 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) @@ -156,7 +153,8 @@ public class Flight // 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.None).ToList(); + // Overrides are per member: a flightmate's hostile track is not one this member follows after it has made it friendly. + var held = picture.Where(e => _tracks[e].Phase != Phase.None && ship.IffOverride(e) != false).ToList(); if (held.Count > 0) { // An engaged track outranks a hailed one; the held anchor is kept only while nothing outranks it. diff --git a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs index 46439667b..1d723812a 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs @@ -150,10 +150,7 @@ public sealed class ConsumableAiTests : IDisposable var maker = cache.RefOf(cache.GetByName("Maker")); EquippableItem Make(string name) where T : CraftedItemData => (EquippableItem) items.CreateInstance(items.CreateLot(cache.GetByName(name), maker, .5f)); - // One fixed orbit far from the fight: a pilot that is not engaged patrols it, as production's would. - var pack = new ZonePack(); - pack.Orbits.Add(cache.Upsert(new OrbitData { FixedPosition = float2(0, 5000) })); - var zone = new Zone(items, new PlanetSettings(), pack, new GalaxyZone { Name = "Test Zone", Owner = null }, null); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); Ship NewShip(string gear) { var ship = new Ship(items, zone, Make("Skiff"), new EntitySettings()) @@ -176,6 +173,8 @@ public sealed class ConsumableAiTests : IDisposable scene.Hold = scene.Agent.CargoBays.Single(); // The agent joins as the zone admits a piloted ship (its Minion and CombatState come from production); the // locker is a gun ship nobody pilots. + // One fixed orbit far from the fight, before the agent joins: a pilot that is not engaged patrols it, as production's would. + zone.Orbits.Add(new CultRecordKey("test-orbit"), new Orbit(new OrbitData { FixedPosition = float2(0, 5000) })); zone.Admit(scene.Agent, piloted: true); zone.Admit(scene.Prey, piloted: false); zone.Admit(scene.Locker, piloted: false); diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs index 21b65ad41..fb7d2406f 100644 --- a/tests/Aetheria.Shared.Tests/FlightTests.cs +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -384,15 +384,16 @@ public sealed class FlightTests : IDisposable Assert.Equal(_items.GameplaySettings.AgentMinHitProbability, combatant.MinHitProbability); } - // The picture is the union of the members' senses while overrides are per member: a track one member has made - // friendly (a truce) is out of the flight's picture though a flightmate senses it as hostile. Kills: the friendly - // override ignored when the picture is built. + // The picture is the union of the members' senses while overrides are per member: a flightmate engages a track + // that a member has made friendly (a truce), and that member neither targets it nor follows its bearing. A truce is + // no grudge, in either order of building. Kills: the member's own friendly override ignored when it holds a bearing, + // or read as a grudge. [Theory] [InlineData(EngageOn.Detection, true)] [InlineData(EngageOn.Detection, false)] [InlineData(EngageOn.Provoked, true)] [InlineData(EngageOn.Provoked, false)] - public void ATrackAMemberHasMadeFriendlyIsNotEngagedByTheFlight(EngageOn on, bool friendlyBuiltFirst) + public void ATrackAMemberHasMadeFriendlyIsNeitherTargetedNorFollowedByIt(EngageOn on, bool friendlyBuiltFirst) { var faction = WithDoctrine(on); Ship friendly, sees; @@ -404,9 +405,9 @@ public sealed class FlightTests : IDisposable Tick(.1f); - Assert.True(sees.Target.Value.IsNone); + Assert.Same(on == EngageOn.Detection ? track : null, sees.Target.Value.Entity); Assert.True(friendly.Target.Value.IsNone); - Assert.Equal(Flight.Phase.None, _zone.Flights.Single(f => f.Contains(_zone.Agents.Single(a => a.Ship == sees))).PhaseOf(track)); + Assert.IsType(_zone.Agents.Single(a => a.Ship == friendly).Task); } [Fact] @@ -864,7 +865,7 @@ public sealed class FlightTests : IDisposable var (turn, thrust, _) = Follow(new float2(vx, vy), 100, new float2(fx, fy)); Assert.Equal(turns, turn != 0f); - Assert.NotEqual(new float2(0, 0), thrust); + if (!turns) Assert.NotEqual(new float2(0, 0), thrust); } // The pull toward a stationary anchor is the error over the standoff, as a fraction of the pilot's top speed: a From 6985ece70b3e1fcfe423b58318a72d4c3559584a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 00:56:20 +0200 Subject: [PATCH 30/35] ConsumableAiTests: the refitted fixture agent starts on patrol and takes one more tick to reach combat Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ConsumableAiTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs index 1d723812a..76306e10d 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs @@ -188,6 +188,7 @@ public sealed class ConsumableAiTests : IDisposable scene.Place(100f, 0f); zone.Update(.05f); zone.Update(.05f); + zone.Update(.05f); // patrol to combat takes a tick the unengaged-root start did not scene.AssertInCombat(); // Read, never re-sampled: the optimum is whatever production derived for the spawned ship. From f11ec4f6769528c090c3518bad598f780e14d0c6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 01:11:15 +0200 Subject: [PATCH 31/35] ConsumableAiTests: the refit tick count reaches combat from the new Minion's patrol start Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ConsumableAiTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs index 76306e10d..9189d2cdc 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs @@ -355,6 +355,7 @@ public sealed class ConsumableAiTests : IDisposable s.Place(30f, 0f); s.Zone.Update(.05f); s.Zone.Update(.05f); + s.Zone.Update(.05f); // the new Minion walks root, patrol, combat } float Optimum() => (float) typeof(CombatState).GetField("_optimumRange", Private).GetValue(s.State); From 5a96035563d76a8fa6fd3ab470dae4a026a269ed Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 01:13:28 +0200 Subject: [PATCH 32/35] ConsumableAiTests: the refit waits for the new Minion to reach combat Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ConsumableAiTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs index 9189d2cdc..4fd7ebbbe 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs @@ -353,9 +353,9 @@ public sealed class ConsumableAiTests : IDisposable s.Agent.EntityInfoGathered[s.Prey] = 1f; if (!s.Agent.VisibleEnemies.Contains(s.Prey)) s.Agent.VisibleEnemies.Add(s.Prey); s.Place(30f, 0f); + // The new Minion walks root, patrol, orbit, combat; the tick after it enters combat samples the optimum. + for (var i = 0; i < 8 && !(s.State is CombatState); i++) s.Zone.Update(.05f); s.Zone.Update(.05f); - s.Zone.Update(.05f); - s.Zone.Update(.05f); // the new Minion walks root, patrol, combat } float Optimum() => (float) typeof(CombatState).GetField("_optimumRange", Private).GetValue(s.State); From 843acb534167ab384748e8d5423d7369feaad5ca Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 03:49:37 +0200 Subject: [PATCH 33/35] faction-play-1: pin the vent in FollowState, pins to 10%, drop the dead LockAlarm Combat.cs loses its unused LockAlarm constant. ConsumableAiTests gains a following agent that vents. FlightTests adds follow-velocity, pull and compliance-entry cases a tenth either side of their thresholds and a hail member that is nearest only with z halved. Co-Authored-By: Claude Opus 5.5 --- .../ServerShared/Agents/States/Combat.cs | 3 --- .../ConsumableAiTests.cs | 27 +++++++++++++++++++ tests/Aetheria.Shared.Tests/FlightTests.cs | 15 ++++++++--- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index 8ee3fa7a5..8ed210567 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -10,9 +10,6 @@ using float2x2 = CultMath.float2x2; public class CombatState : BaseState { private const int DPS_SAMPLE_COUNT = 32; - // The Strength of one Track paint in this ship's own IncomingPaints: a fire-control emitter tracking this ship - // that hard (rulings lock-warning-strength, sensor-stat-set item 4, launcher-lock-solution-quality). - private const float LockAlarm = .5f; // Overdrive only to close a target farther than this many optimum ranges, while heading within this many degrees of it. private const float OverdriveRangeFactor = 2f; private const float OverdriveArcDegrees = 15f; diff --git a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs index 4fd7ebbbe..32a45c16b 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs @@ -519,4 +519,31 @@ public sealed class ConsumableAiTests : IDisposable Assert.True(s.Active(s.Vent)); Assert.Equal(0, s.Carried); } + + // A following agent has a task to follow and no target, and it vents all the same (ruling vent-in-any-state names + // patrol, follow and combat). The agent is driven directly: the zone's flight is the sole writer of its Task. + [Fact] + public void AFollowingAgentVentsToo() + { + var s = Build(carryVent: true); + var agent = s.Zone.Agents.Single(); + agent.VisibleEnemies.Clear(); + agent.Task = new FollowTask { Anchor = s.Prey, Standoff = 100 }; + for (var i = 0; i < 6 && !(s.State is FollowState); i++) agent.Update(.05f); + Assert.True(s.State is FollowState, $"the agent is in {s.State?.GetType().Name}"); + + void Paint(float strength) + { + s.Agent.ReceivePaint(s.Locker, s.Locker.Equipment.Single(i => i.Behaviors.Any(b => b is LockWeapon)), PaintMode.Track, strength); + s.Agent.PublishInbox(); + agent.Update(.05f); + } + Paint(.3f); + Assert.False(s.Active(s.Vent)); + Paint(.9f); + + Assert.True(s.State is FollowState); + Assert.True(s.Active(s.Vent)); + Assert.Equal(0, s.Carried); + } } diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs index fb7d2406f..d2a035ed7 100644 --- a/tests/Aetheria.Shared.Tests/FlightTests.cs +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -138,14 +138,14 @@ public sealed class FlightTests : IDisposable } public static System.Collections.Generic.IEnumerable BuildOrders() => - from order in new[] { "NTFM", "MFTN", "TMNF", "FNMT", "NMTF", "TFMN" } + from order in new[] { "NTFMZ", "MFTNZ", "TMZNF", "FNMTZ", "ZNMTF", "TFMZN" } select new object[] { order }; // The track is off the origin. N is the nearest member by distance, on the diagonal; T is nearer than N in x alone, F // nearer in z alone, and M nearest by the sum of the axis distances (60 against N's 80), all farther in distance. // Built in several orders: the speaker is the nearest by distance, not first or last built, nor the nearest on one // axis or by the sum of components. Kills: the hail from the first or last member, or from the nearest by x, by z - // or by the sum of components. + // or by the sum of components (Z is nearest with z halved: 70 against N's 57, 35 against 45). [Theory] [MemberData(nameof(BuildOrders))] public void GraceHailsOnceFromTheNearestThenEngages(string order) @@ -153,7 +153,7 @@ public sealed class FlightTests : IDisposable var faction = WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold."); var (tx, tz) = (300f, -200f); var spots = new System.Collections.Generic.Dictionary - { ['N'] = (tx + 40, tz + 40), ['T'] = (tx + 5, tz + 120), ['F'] = (tx - 200, tz + 10), ['M'] = (tx + 60, tz) }; + { ['N'] = (tx + 40, tz + 40), ['T'] = (tx + 5, tz + 120), ['F'] = (tx - 200, tz + 10), ['M'] = (tx + 60, tz), ['Z'] = (tx, tz + 70) }; var ships = order.ToDictionary(c => c, c => Pilot(faction, spots[c].x, spots[c].z)); var track = Track(tx, tz); foreach (var ship in ships.Values) See(ship, track); @@ -164,6 +164,7 @@ public sealed class FlightTests : IDisposable Assert.False(ships['T'].Messages.ContainsKey("Hold.")); Assert.False(ships['F'].Messages.ContainsKey("Hold.")); Assert.False(ships['M'].Messages.ContainsKey("Hold.")); + Assert.False(ships['Z'].Messages.ContainsKey("Hold.")); Assert.All(ships.Values, m => Assert.True(m.Target.Value.IsNone)); Assert.All(_zone.Agents, a => Assert.IsType(a.Task)); @@ -207,6 +208,8 @@ public sealed class FlightTests : IDisposable [InlineData(12f, true, .6f, .8f)] [InlineData(8f, false, .7071f, .7071f)] // 11.3 by the sum of components, 8 by length [InlineData(12f, true, .7071f, .7071f)] // 8.5 by the larger component, 12 by length + [InlineData(9.5f, false, 1f, 0f)] // within a tenth of ComplySpeed, each side + [InlineData(10.5f, true, 1f, 0f)] public void ComplianceIsJudgedOnComplySpeedAndNotOnGrace(float speed, bool engaged, float dx, float dy) { var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 3, hail: "Hold.", comply: 10)); @@ -860,6 +863,10 @@ public sealed class FlightTests : IDisposable [InlineData(-30f, 0f, 0f, 1f, true)] [InlineData(15f, 0f, 0f, 1f, false)] [InlineData(0f, 15f, 1f, 0f, false)] + [InlineData(22f, 0f, 0f, 1f, true)] // a tenth under the scale still passes 20 only at full scale + [InlineData(19f, 0f, 0f, 1f, false)] // a tenth over it would pass 20 + [InlineData(0f, 22f, 1f, 0f, true)] + [InlineData(0f, 19f, 1f, 0f, false)] public void AFollowerAtItsStandoffMatchesTheAnchorsVelocityAtFullScale(float vx, float vy, float fx, float fy, bool turns) { var (turn, thrust, _) = Follow(new float2(vx, vy), 100, new float2(fx, fy)); @@ -873,6 +880,8 @@ public sealed class FlightTests : IDisposable [Theory] [InlineData(30f, true)] [InlineData(15f, false)] + [InlineData(22f, true)] + [InlineData(19f, false)] public void AFollowersPullIsTheErrorOverTheStandoffAtTopSpeed(float wantedPull, bool turns) { var top = Follow(new float2(0, 0), 100, new float2(1, 0)).topSpeed; From 199f1b1b943e2e42a88c74a4f55d996740dc1526 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 03:51:43 +0200 Subject: [PATCH 34/35] ConsumableAiTests: the following agent clears the Ship's VisibleEnemies The previous commit does not build: VisibleEnemies is on the Ship, not the Agent. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ConsumableAiTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs index 32a45c16b..688e27751 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs @@ -527,7 +527,7 @@ public sealed class ConsumableAiTests : IDisposable { var s = Build(carryVent: true); var agent = s.Zone.Agents.Single(); - agent.VisibleEnemies.Clear(); + s.Agent.VisibleEnemies.Clear(); agent.Task = new FollowTask { Anchor = s.Prey, Standoff = 100 }; for (var i = 0; i < 6 && !(s.State is FollowState); i++) agent.Update(.05f); Assert.True(s.State is FollowState, $"the agent is in {s.State?.GetType().Name}"); From 0f2fc859de5b4c02de052bf4ea2c3a670fe1ee4c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 04:03:08 +0200 Subject: [PATCH 35/35] ConsumableAiTests: the following agent leaves combat before it is set to follow The agent starts in combat; two zone ticks with nothing sensed return it to patrol, as the patrol test does. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ConsumableAiTests.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs index 688e27751..f86dc1e07 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs @@ -528,6 +528,9 @@ public sealed class ConsumableAiTests : IDisposable var s = Build(carryVent: true); var agent = s.Zone.Agents.Single(); s.Agent.VisibleEnemies.Clear(); + s.Zone.Update(.05f); + s.Zone.Update(.05f); + Assert.True(s.Agent.Target.Value.IsNone); agent.Task = new FollowTask { Anchor = s.Prey, Standoff = 100 }; for (var i = 0; i < 6 && !(s.State is FollowState); i++) agent.Update(.05f); Assert.True(s.State is FollowState, $"the agent is in {s.State?.GetType().Name}");