diff --git a/Assets/Scripts/ServerShared/Agents/Agent.cs b/Assets/Scripts/ServerShared/Agents/Agent.cs index 1d51a855f..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; @@ -23,11 +26,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; @@ -49,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()) @@ -58,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/Flight.cs b/Assets/Scripts/ServerShared/Agents/Flight.cs new file mode 100644 index 000000000..1fe54844b --- /dev/null +++ b/Assets/Scripts/ServerShared/Agents/Flight.cs @@ -0,0 +1,185 @@ +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; + } + + // 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); + // 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. + 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 => _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) }; + } + 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..41c0e3fe1 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 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); + 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, () => 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 42f04e3cd..8ed210567 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -10,13 +10,11 @@ using float2x2 = CultMath.float2x2; public class CombatState : BaseState { private const int DPS_SAMPLE_COUNT = 32; - // The Strength of one Track paint in this ship's own IncomingPaints: a fire-control emitter tracking this ship - // that hard (rulings lock-warning-strength, sensor-stat-set item 4, launcher-lock-solution-quality). - private const float LockAlarm = .5f; // Overdrive only to close a target farther than this many optimum ranges, while heading within this many degrees of it. private const float OverdriveRangeFactor = 2f; private const float OverdriveArcDegrees = 15f; private float _optimumRange; + public float OptimumRange => _optimumRange; private bool _optimumStale = true; private readonly List<(int index, float dps)> _availableGroups = new List<(int index, float dps)>(); private readonly List _availableLockingWeapons = new List(); @@ -141,7 +139,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(); @@ -154,25 +152,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() @@ -200,7 +186,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/Agents/Tasks/PatrolOrbits.cs b/Assets/Scripts/ServerShared/Agents/Tasks/PatrolOrbits.cs index f2a26771d..4fc844985 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 && ReferenceEquals(agent.Task, Task), NextTarget)); } } \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/Behaviors/TurretController.cs b/Assets/Scripts/ServerShared/Behaviors/TurretController.cs index 88578c637..cb65eba58 100644 --- a/Assets/Scripts/ServerShared/Behaviors/TurretController.cs +++ b/Assets/Scripts/ServerShared/Behaviors/TurretController.cs @@ -79,7 +79,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..4a9b636ed --- /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 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 +} + +// 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 1efea01b2..01b5509d5 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -1286,6 +1286,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 202ed7d24..c94caded8 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -95,12 +95,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 02904e461..bb8e9adfe 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -69,6 +69,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. @@ -155,6 +160,16 @@ public class Zone _entities.ObserveRemove().Subscribe(remove => _departures.Add(new Departure(remove.Value, remove.Value.Position.xz, remove.Value.Visibility))); + // 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) @@ -283,10 +298,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; } @@ -327,7 +345,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); var entities = Entities.ToArray(); diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index 7cc7c9d58..b81439706 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ba78312744521762069315f8b8c491f7619f0410249abcfa90291a11b9e29d7b -size 12745154 +oid sha256:d54c38b50a40d088fb418f6c276e02144e39a6dcd7d196a0630a521a2e4cb662 +size 12745351 diff --git a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs index ca1081585..f86dc1e07 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". // @@ -173,18 +173,22 @@ 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.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. 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); 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. @@ -343,9 +347,14 @@ 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 + // 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); + // 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); } float Optimum() => (float) typeof(CombatState).GetField("_optimumRange", Private).GetValue(s.State); @@ -490,4 +499,54 @@ public sealed class ConsumableAiTests : IDisposable 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); + } + + // 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(); + 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}"); + + 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/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 21525805c..21cde60cb 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -277,7 +277,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); @@ -292,7 +292,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); @@ -314,6 +314,67 @@ 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); + } + + // 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(.005f, false)] + [InlineData(-.005f, 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 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(50f, 0f, 110f)] + [InlineData(200f, 0f, 220f)] + [InlineData(50f, 40f, 40f)] + [InlineData(50f, 250f, 250f)] + public void AFlightHoldsOffAtTheDefaultOrAuthoredStandoff(float range, float holdStandoff, float expected) + { + 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; + 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. @@ -331,7 +392,7 @@ public sealed class FireControlPerWeaponTests : IDisposable public void CombatAimsAtTheHeightATargetRidesAt() { var r = Build(velocity: 500f, targetLayerHeight: 40f); - 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); @@ -366,7 +427,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] @@ -375,7 +436,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. @@ -389,7 +450,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 ==== @@ -599,7 +660,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 @@ -610,7 +671,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); @@ -628,7 +689,7 @@ public sealed class FireControlPerWeaponTests : IDisposable var r = Build(range: 10f, targetRange: 20f, decoy: true); Assert.True(r.Gun.Weapon.RangeDamagePerSecond(5f) > r.Decoy.Weapon.RangeDamagePerSecond(5f), "fixture: the short gun is the stronger group"); Assert.Equal(0f, r.Gun.Weapon.RangeDamagePerSecond(20f)); // the damage function already reads 0 beyond Range - 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); @@ -650,7 +711,7 @@ public sealed class FireControlPerWeaponTests : IDisposable var decoy = r.Decoy.Weapon.RangeDamagePerSecond(60f); Assert.Equal(gunPicked, r.Gun.Weapon.RangeDamagePerSecond(60f) > decoy); Assert.True(r.Gun.Weapon.RangeDamagePerSecond(effective == 10f ? 30f : 120f) > decoy == !gunPicked, "fixture: the scaled distance reads the other way"); - 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); @@ -667,7 +728,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); @@ -686,8 +747,37 @@ 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) + { + var combat = new CombatState(new Minion(r.Shooter, new RoleDoctrine { RangeExponent = exponent, MinHitProbability = .2f })); + combat.Update(0f); // the optimum is derived at the first Update + return combat.OptimumRange; + } + + 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/FireControlTests.cs b/tests/Aetheria.Shared.Tests/FireControlTests.cs index 2d9a91d6b..68ab9a6db 100644 --- a/tests/Aetheria.Shared.Tests/FireControlTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlTests.cs @@ -237,7 +237,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..d2a035ed7 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -0,0 +1,1121 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using CultMath; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +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 +// 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, float z = 0) + { + var ship = _world.NewShip(_items, _zone, faction, piloted: true); + ship.Position = new float3(x, 0, z); + return ship; + } + + private Ship Track(float x = 100, float z = 0) + { + var track = _world.NewShip(_items, _zone, Hostile); + track.Position = new float3(x, 0, z); + 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] = MathF.BitDecrement(.8f); // just under the threshold + b.EntityInfoGathered[track] = MathF.BitDecrement(.8f); + + Tick(.1f); + Assert.True(a.Target.Value.IsNone); + Assert.True(b.Target.Value.IsNone); + + b.EntityInfoGathered[track] = .8f; // exactly the threshold + 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); + } + + public static System.Collections.Generic.IEnumerable BuildOrders() => + 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 (Z is nearest with z halved: 70 against N's 57, 35 against 45). + [Theory] + [MemberData(nameof(BuildOrders))] + public void GraceHailsOnceFromTheNearestThenEngages(string order) + { + 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), ['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); + + Tick(.1f); + + 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.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)); + + Tick(4.8f); + Assert.All(ships.Values, m => Assert.True(m.Target.Value.IsNone)); + + Tick(.4f); + Assert.All(ships.Values, m => Assert.Same(track, m.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); + } + + // 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, 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)] + [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)); + var track = Track(); + track.Velocity = new float2(dx, dy) * speed; + 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() + { + 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, -200, 50); + var near = Pilot(null, -200, 50); + var anchor = Track(); + + float TurnAfterFollowing(Ship ship, float3 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); + minion.Update(.1f); + return ship.Turn; + } + + // 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); + } + + [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)); + } + + [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); + + [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); + Assert.Equal(new[] { leaves, stays }, _zone.Agents.Select(a => a.Ship)); + Assert.Equal(2, flight.Count); + + _zone.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); + } + + 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 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() + { + 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); + } + + // 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 ATrackAMemberHasMadeFriendlyIsNeitherTargetedNorFollowedByIt(EngageOn on, bool friendlyBuiltFirst) + { + var faction = WithDoctrine(on); + 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); + + Tick(.1f); + + Assert.Same(on == EngageOn.Detection ? track : null, sees.Target.Value.Entity); + Assert.True(friendly.Target.Value.IsNone); + Assert.IsType(_zone.Agents.Single(a => a.Ship == friendly).Task); + } + + [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); + } + + // 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" }, 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(990, 0, 0); + Tick(.1f); + 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() + { + 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(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() + { + 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, -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); + 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)); + } + + public static System.Collections.Generic.IEnumerable UnseenGeometries() => + from distance in new[] { 90f, 900f } + from blindIndex in new[] { 0, 1, 2 } + from graced in new[] { false, true } + from right in new[] { true, false } + 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 (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, int blindIndex, bool graced, bool right, float x, float z) + { + var faction = WithDoctrine(EngageOn.Detection, grace: graced ? 5 : 0, hail: "Hold."); + 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[0].SetIff(track, true); + } + + Tick(.1f); + + 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. 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, -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); + 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); + + // 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); + 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. + 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); + Assert.IsType(CurrentState(minion)); + 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)); + UpdateUntil(minion, () => CurrentState(minion) is CombatState); + + ship.SetTarget(TargetRef.None); + UpdateUntil(minion, () => !(CurrentState(minion) is CombatState)); + UpdateUntil(minion, () => CurrentState(minion) is PatrolOrbitsState || CurrentState(minion) is MoveToOrbitState); + } + + [Fact] + public void CombatHoldsWhileTheTargetStands() + { + var ship = Pilot(null); + var minion = PatrollingMinion(ship); + var track = Track(); + See(ship, track); + ship.SetTarget(new TargetRef(track)); + UpdateUntil(minion, () => CurrentState(minion) is CombatState); + + 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)); + } + + // 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(); + track.PresencePermitted = null; + See(member, track); + + Tick(.1f); + + Assert.True(member.Target.Value.IsNone); + } + + // 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) + { + 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); + } + + // 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)] + [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)); + + Assert.Equal(turns, turn != 0f); + 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 + // 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)] + [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; + Assert.True(top >= 60f, "the fixture needs a top speed above the pull it asks for"); + var (turn, _, _) = Follow(new float2(0, 0), 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. + [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, + CultDocumentMessagePackSerialization.OptionsFor(typeof(Faction).Assembly))); + + Assert.Equal(18, slots.Length); + Assert.Null(slots[16]); + var doctrine = Assert.IsType(slots[17]); + 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] + 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] + public void TheDoctrineRoundTrips() + { + var path = TempCatalog(); + try + { + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + cache.Upsert(new VerseGrammar { Revision = 1 }); + 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 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() + { + 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" }) 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 7fe1d9127..97a1f9435 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,13 +82,13 @@ public sealed class IffAndCombatTests : IDisposable Assert.True(ship.TryEquip(gun, HardpointCell)); } - zone.Admit(ship, piloted: false); + zone.Admit(ship, piloted); 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/LayoutAimTests.cs b/tests/Aetheria.Shared.Tests/LayoutAimTests.cs index c44c5834a..9a0c2d7be 100644 --- a/tests/Aetheria.Shared.Tests/LayoutAimTests.cs +++ b/tests/Aetheria.Shared.Tests/LayoutAimTests.cs @@ -416,7 +416,7 @@ public sealed partial class ContactRevealTests var bay = AddBay(float3(0, 0, 40), gun: true); var gun = Guns(bay).Single(); var ai = Add(float3(0, 0, 0), new[] { "Eye" }); - _zone.Agents.Add(new Minion(ai)); + _zone.Agents.Add(new Minion(ai, FactionDoctrine.Default(ai.ItemManager.GameplaySettings).Combatant)); ai.SetTarget(bay); Hold(ai, 12, 1f, bay); Assert.True(ReferenceEquals(bay, ai.Target.Value.Entity), "fixture: the AI keeps the ship as its target"); diff --git a/tests/Aetheria.Shared.Tests/MineTests.cs b/tests/Aetheria.Shared.Tests/MineTests.cs index e0685f3bf..ddb805206 100644 --- a/tests/Aetheria.Shared.Tests/MineTests.cs +++ b/tests/Aetheria.Shared.Tests/MineTests.cs @@ -677,9 +677,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)); } @@ -721,7 +721,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/tests/Aetheria.Shared.Tests/ScannerTests.cs b/tests/Aetheria.Shared.Tests/ScannerTests.cs index e0ddb30e9..b470d2ae0 100644 --- a/tests/Aetheria.Shared.Tests/ScannerTests.cs +++ b/tests/Aetheria.Shared.Tests/ScannerTests.cs @@ -379,7 +379,7 @@ public sealed partial class ContactRevealTests target.Claim(reactor, DataOf("Radiator")); var player = Add(float3(-20, 0, 0), tools: new[] { "Scanner" }); var ai = Add(float3(20, 0, 0), tools: new[] { "Scanner" }); - var agent = new Minion(ai); // the AI pilot, ticked in Zone.Update beside the scan: it takes the combat state once it has a target + var agent = new Minion(ai, FactionDoctrine.Default(ai.ItemManager.GameplaySettings).Combatant); // the AI pilot, ticked in Zone.Update beside the scan: it takes the combat state once it has a target _zone.Agents.Add(agent); Assert.Single(_zone.Agents); // fixture: only the second ship has an agent foreach (var scanner in new[] { player, ai }) diff --git a/tests/Aetheria.Shared.Tests/SolutionTests.cs b/tests/Aetheria.Shared.Tests/SolutionTests.cs index 94f9b4e7c..1e0a8d090 100644 --- a/tests/Aetheria.Shared.Tests/SolutionTests.cs +++ b/tests/Aetheria.Shared.Tests/SolutionTests.cs @@ -175,7 +175,7 @@ public sealed partial class FireControlCut124Tests Assert.True(solution.Bears && solution.Free); Assert.True(FireControl.ArcPermitsFire(e.Weapon, e.Shooter)); - Assert.True(FireControl.AgentFires(e.Weapon, e.Shooter, e.Target)); + Assert.True(FireControl.AgentFires(e.Weapon, e.Shooter, e.Target, e.Shooter.ItemManager.GameplaySettings.AgentMinHitProbability)); Near(intercept, solution.Direction); } diff --git a/tests/Aetheria.Shared.Tests/SteeringTests.cs b/tests/Aetheria.Shared.Tests/SteeringTests.cs index d2abbbbaa..63588e865 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; @@ -326,7 +326,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)); @@ -369,7 +369,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; 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) diff --git a/tools/AetherDb/DoctrineCatalog.cs b/tools/AetherDb/DoctrineCatalog.cs new file mode 100644 index 000000000..7dae56268 --- /dev/null +++ b/tools/AetherDb/DoctrineCatalog.cs @@ -0,0 +1,83 @@ +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. 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 = + { + ("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 => f.Name == name); + if (faction == null) + { + output.WriteLine($"{name}: no Faction record, doctrine not written"); + 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.##}"); + faction.Doctrine = doctrine; + changed.Add(faction); + } + + if (!apply) + { + output.WriteLine("\nDry run. Pass \"apply\" to author them."); + 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); + }); + output.WriteLine($"\nAuthored {changed.Count} doctrines in Aetheria.cc"); + return 0; + } +} diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index b2af11686..61691d180 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -56,8 +56,9 @@ public static class Program case "generic-designs": return GenericDesigns(args.Contains("apply")); case "inspection-scanner": return InspectionScannerCatalog(args.Contains("apply")); case "product-lines": return ProductLinesCommand.Run(args.Skip(1).ToArray()); + 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], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], product-lines weapons [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], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], product-lines weapons [apply], doctrine-catalog [apply]"); return 1; } } @@ -697,7 +698,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)) { @@ -707,7 +708,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"); 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] }, };