diff --git a/Assets/Scripts/ServerShared/Agents/Agent.cs b/Assets/Scripts/ServerShared/Agents/Agent.cs index 1d51a855f..b1900a3f8 100644 --- a/Assets/Scripts/ServerShared/Agents/Agent.cs +++ b/Assets/Scripts/ServerShared/Agents/Agent.cs @@ -23,11 +23,14 @@ public class Agent public Ship Ship { get; } public ItemManager ItemManager { get; } public GameplaySettings Settings { get; } + // How this pilot fights: its faction's doctrine for its role (Zone.CreateAgent resolves it). + public RoleDoctrine Doctrine { get; } public float TopSpeed => _velocityLimit?.Limit ?? 100; - public Agent(Ship ship) + public Agent(Ship ship, RoleDoctrine doctrine) { Ship = ship; + Doctrine = doctrine; ItemManager = Ship.ItemManager; Settings = Ship.ItemManager.GameplaySettings; diff --git a/Assets/Scripts/ServerShared/Agents/Flight.cs b/Assets/Scripts/ServerShared/Agents/Flight.cs new file mode 100644 index 000000000..1e6cccce1 --- /dev/null +++ b/Assets/Scripts/ServerShared/Agents/Flight.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CultMath; +using static CultMath.math; + +// One flight per (zone, faction) of piloted ships; unaffiliated ships form one flight of their own. The flight alone +// decides what its members do: which hostile each engages (Ship.Target) and what each does otherwise (Agent.Task). +// The agent graph executes those two and decides nothing. The faction's doctrine (FactionDoctrine) holds the numbers. +// +// The picture is the union of the members' VisibleEnemies. A track passes through phases the flight owns, runtime only: +// None, Hailing (grace running, one hail spoken), Complied (slow enough at the grace's end: followed, not engaged) and +// Engaged. A member still fires only at what its own VisibleEntities hold (FireControl), so the shared picture never +// lets it fire at what it cannot see. +public class Flight +{ + public enum Phase { None, Hailing, Complied, Engaged } + + private class Track + { + public Phase Phase; + public double Start; + } + + private class Member + { + public Agent Agent; + public PatrolOrbitsTask Patrol; + } + + private readonly FactionDoctrine _doctrine; + private readonly GameplaySettings _settings; + private readonly List _members = new List(); + private readonly Dictionary _tracks = new Dictionary(); + private double _time; + + // No doctrine fights as FactionDoctrine.Default; a doctrine that leaves a role unauthored gets that role's default. + public Flight(FactionDoctrine doctrine, GameplaySettings settings) + { + var fallback = FactionDoctrine.Default(settings); + _doctrine = doctrine ?? fallback; + _settings = settings; + Combatant = _doctrine.Combatant ?? fallback.Combatant; + } + + public RoleDoctrine Combatant { get; } + public int Count => _members.Count; + public bool Contains(Agent agent) => _members.Any(m => m.Agent == agent); + + public Phase PhaseOf(Entity track) => _tracks.TryGetValue(track, out var t) ? t.Phase : Phase.None; + + public void Join(Agent agent, PatrolOrbitsTask circuit) + { + _members.Add(new Member { Agent = agent, Patrol = circuit }); + agent.Task = circuit; + } + + public void Leave(Agent agent) => _members.RemoveAll(m => m.Agent == agent); + + public void Update(float dt) + { + _time += dt; + var picture = _members.SelectMany(m => m.Agent.Ship.VisibleEnemies).Distinct().ToList(); + foreach (var gone in _tracks.Keys.Where(t => !picture.Contains(t)).ToList()) _tracks.Remove(gone); + + foreach (var entity in picture) + { + if (!_tracks.TryGetValue(entity, out var track)) _tracks[entity] = track = new Track(); + Advance(entity, track); + } + + foreach (var member in _members) Order(member, picture); + } + + // A grudge is a hostile IFF override that any member holds on the track. + private bool Grudged(Entity entity) => _members.Any(m => m.Agent.Ship.IffOverride(entity) == true); + + private bool Engageable(Entity entity, bool grudge) + { + switch (_doctrine.EngageOn) + { + case EngageOn.Never: return false; + case EngageOn.Detection: return true; + case EngageOn.Identified: + return grudge || _members.Any(m => m.Agent.Ship.EntityInfoGathered.TryGetValue(entity, out var info) && + info >= _settings.TargetGearInfoThreshold); + case EngageOn.Provoked: return grudge; + case EngageOn.Trespass: return grudge || !(entity.PresencePermitted?.Value ?? true); + default: return false; + } + } + + private void Advance(Entity entity, Track track) + { + if (track.Phase == Phase.Engaged) return; + var grudge = Grudged(entity); + if (!Engageable(entity, grudge)) + { + track.Phase = Phase.None; + return; + } + + // A grudge is already a quarrel: no grace to give. + if (grudge) + { + track.Phase = Phase.Engaged; + return; + } + + switch (track.Phase) + { + case Phase.None: + if (_doctrine.Grace > 0) + { + track.Phase = Phase.Hailing; + track.Start = _time; + if (!string.IsNullOrEmpty(_doctrine.Hail)) Nearest(entity)?.Agent.Ship.SetMessage(_doctrine.Hail); + } + else track.Phase = Phase.Engaged; + break; + case Phase.Hailing: + if (_time >= track.Start + _doctrine.Grace) + track.Phase = _doctrine.ComplySpeed > 0 && length(entity.Velocity) < _doctrine.ComplySpeed + ? Phase.Complied + : Phase.Engaged; + break; + case Phase.Complied: + if (length(entity.Velocity) >= _doctrine.ComplySpeed) track.Phase = Phase.Engaged; + break; + } + } + + private Member Nearest(Entity entity) => + _members.OrderBy(m => lengthsq(m.Agent.Ship.Position.xz - entity.Position.xz)).FirstOrDefault(); + + private void Order(Member member, List picture) + { + var ship = member.Agent.Ship; + var seen = ship.VisibleEnemies; + + // Engage: keep the current target while it stays engaged, else the nearest engaged track this member can see. + var engaged = picture.Where(e => _tracks[e].Phase == Phase.Engaged && seen.Contains(e)).ToList(); + if (engaged.Count > 0) + { + var current = ship.Target.Value.Entity; + var pick = current != null && engaged.Contains(current) + ? current + : engaged.OrderBy(e => lengthsq(ship.Position.xz - e.Position.xz)).First(); + Aim(ship, pick); + return; + } + + Aim(ship, null); + var held = picture.Where(e => _tracks[e].Phase == Phase.Hailing || _tracks[e].Phase == Phase.Complied).ToList(); + if (held.Count > 0) + { + var anchor = member.Agent.Task is FollowTask follow && held.Contains(follow.Anchor) + ? follow.Anchor + : held.OrderBy(e => lengthsq(ship.Position.xz - e.Position.xz)).First(); + if (!(member.Agent.Task is FollowTask current) || current.Anchor != anchor) + member.Agent.Task = new FollowTask { Anchor = anchor, Standoff = Standoff(ship) }; + } + else if (member.Agent.Task != member.Patrol) + member.Agent.Task = member.Patrol; + } + + private static void Aim(Ship ship, Entity target) + { + var wanted = target == null ? TargetRef.None : new TargetRef(target); + if (!ship.Target.Value.Equals(wanted)) ship.SetTarget(wanted); + } + + private float Standoff(Ship ship) + { + if (Combatant.HoldStandoff > 0) return Combatant.HoldStandoff; + return 1.1f * ship.Weapons.Select(w => w.Range).DefaultIfEmpty(0f).Max(); + } +} diff --git a/Assets/Scripts/ServerShared/Agents/Flight.cs.meta b/Assets/Scripts/ServerShared/Agents/Flight.cs.meta new file mode 100644 index 000000000..bcbf8c60e --- /dev/null +++ b/Assets/Scripts/ServerShared/Agents/Flight.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5f6c82a74e5b4bc1b8c79e0d242e3ec2 diff --git a/Assets/Scripts/ServerShared/Agents/Minion.cs b/Assets/Scripts/ServerShared/Agents/Minion.cs index 7540cb5c8..80ee3c34f 100644 --- a/Assets/Scripts/ServerShared/Agents/Minion.cs +++ b/Assets/Scripts/ServerShared/Agents/Minion.cs @@ -1,23 +1,30 @@ - using System.Linq; using UniRx; +// The agent graph for a piloted ship. It executes the Task and Target its flight writes (Flight.cs) and decides +// neither: patrol and follow follow the Task kind, combat follows the Target. public class Minion : Agent { - public Minion(Ship ship) : base(ship) + public Minion(Ship ship, RoleDoctrine doctrine) : base(ship, doctrine) { var patrolState = new PatrolOrbitsState(this); - _rootState.AddTransition(patrolState, - () => Task is PatrolOrbitsTask, - () => patrolState.Task = Task as PatrolOrbitsTask); + var followState = new FollowState(this); + void Patrol() => patrolState.Task = Task as PatrolOrbitsTask; + _rootState.AddTransition(patrolState, () => Task is PatrolOrbitsTask, Patrol); + _rootState.AddTransition(followState, () => Task is FollowTask); - Ship.VisibleEnemies.ObserveAdd().Where(_ => Ship.Target.Value.IsNone).Subscribe(add => Ship.SetTarget(add.Value)); + // A Task change switches the substate in one update, from wherever the pilot is in the old task. includeChildren + // reaches the sub-states (MoveToOrbit) that Update actually sits in. + patrolState.AddTransition(followState, () => Task is FollowTask, null, true, _rootState); + followState.AddTransition(patrolState, () => Task is PatrolOrbitsTask, Patrol, true, _rootState); + patrolState.AddTransition(_rootState, () => !(Task is PatrolOrbitsTask), null, true, _rootState); + followState.AddTransition(_rootState, () => !(Task is FollowTask), null, true, _rootState); var combatState = new CombatState(this); _rootState.AddTransition(combatState, () => Ship.Target.Value.Entity != null, null, true, _rootState); - + combatState.AddTransition(_rootState, () => Ship.Target.Value.Entity == null, null, true, _rootState); } -} \ No newline at end of file +} diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index 092c6af4c..b64cd7d06 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -11,6 +11,7 @@ public class CombatState : BaseState { private const int DPS_SAMPLE_COUNT = 32; private float _optimumRange; + public float OptimumRange => _optimumRange; private readonly List<(int index, float dps)> _availableGroups = new List<(int index, float dps)>(); private readonly List _availableLockingWeapons = new List(); @@ -118,7 +119,7 @@ public class CombatState : BaseState // fires at, and "the target is designated" for a fused one. foreach (var weapon in _agent.Ship.WeaponGroups[selectedGroup].weapons) { - if (FireControl.AgentFires(weapon, _agent.Ship, target)) + if (FireControl.AgentFires(weapon, _agent.Ship, target, _agent.Doctrine.MinHitProbability)) weapon.Activate(); else if (weapon.Firing) weapon.Deactivate(); @@ -156,7 +157,7 @@ public class CombatState : BaseState } // Multiply by range raised to a (sublinear) exponent to bias DPS preference towards longer ranges - dps *= pow(range, _agent.Settings.AgentRangeExponent); + dps *= pow(range, _agent.Doctrine.RangeExponent); if (dps > optimumDPS) { diff --git a/Assets/Scripts/ServerShared/Agents/States/Follow.cs b/Assets/Scripts/ServerShared/Agents/States/Follow.cs new file mode 100644 index 000000000..908ecd257 --- /dev/null +++ b/Assets/Scripts/ServerShared/Agents/States/Follow.cs @@ -0,0 +1,26 @@ +using CultMath; +using static CultMath.math; +using float2 = CultMath.float2; + +// Hold a standoff from the anchor: match its velocity, and close or open the gap in proportion to the error. +public class FollowState : BaseState +{ + public FollowState(Agent agent) : base(agent) { } + + public override void Update(float delta) + { + var task = _agent.Task as FollowTask; + if (task?.Anchor == null) return; + + var toAnchor = task.Anchor.Position.xz - _agent.Ship.Position.xz; + var distance = length(toAnchor); + var direction = distance > 0 ? toAnchor / distance : _agent.Ship.Direction; + // Positive error: too far, close. Negative: too near, open. + var error = distance - task.Standoff; + var pull = direction * (float) sign(error) * saturate(abs(error) / max(task.Standoff, 1f)); + var desired = task.Anchor.Velocity + pull * _agent.TopSpeed; + + _agent.Ship.Aim = float3(direction.x, 0, direction.y); + _agent.Accelerate(desired); + } +} diff --git a/Assets/Scripts/ServerShared/Agents/States/Follow.cs.meta b/Assets/Scripts/ServerShared/Agents/States/Follow.cs.meta new file mode 100644 index 000000000..f44ed42c5 --- /dev/null +++ b/Assets/Scripts/ServerShared/Agents/States/Follow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5cf86244ca6648a99f304b061b66348a diff --git a/Assets/Scripts/ServerShared/Agents/Tasks/AgentTask.cs b/Assets/Scripts/ServerShared/Agents/Tasks/AgentTask.cs index 26b70716c..9b5a6d773 100644 --- a/Assets/Scripts/ServerShared/Agents/Tasks/AgentTask.cs +++ b/Assets/Scripts/ServerShared/Agents/Tasks/AgentTask.cs @@ -29,6 +29,7 @@ public enum TaskType Tow, Defend, Attack, - Explore + Explore, + Follow } diff --git a/Assets/Scripts/ServerShared/Agents/Tasks/FollowTask.cs b/Assets/Scripts/ServerShared/Agents/Tasks/FollowTask.cs new file mode 100644 index 000000000..4e09d0a35 --- /dev/null +++ b/Assets/Scripts/ServerShared/Agents/Tasks/FollowTask.cs @@ -0,0 +1,11 @@ +using System; +using MessagePack; + +// Keep station on an entity at a standoff: the flight hailing a track holds its members here (Flight.cs). +// Runtime only, like every agent task. +public class FollowTask : AgentTask +{ + [IgnoreMember] public Entity Anchor; + [IgnoreMember] public float Standoff; + [IgnoreMember] public override TaskType Type => TaskType.Follow; +} diff --git a/Assets/Scripts/ServerShared/Agents/Tasks/FollowTask.cs.meta b/Assets/Scripts/ServerShared/Agents/Tasks/FollowTask.cs.meta new file mode 100644 index 000000000..009e9c4b1 --- /dev/null +++ b/Assets/Scripts/ServerShared/Agents/Tasks/FollowTask.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6bab02326ff64e29ad2b81f0fc1483b5 diff --git a/Assets/Scripts/ServerShared/Behaviors/TurretController.cs b/Assets/Scripts/ServerShared/Behaviors/TurretController.cs index 654ee5ec1..be092f7a0 100644 --- a/Assets/Scripts/ServerShared/Behaviors/TurretController.cs +++ b/Assets/Scripts/ServerShared/Behaviors/TurretController.cs @@ -80,7 +80,7 @@ public class TurretController : Behavior, IInitializableBehavior { // The same per-weapon decision Combat.cs makes; the range test is part of it (a shot out of range // is not designated and prices at zero). - if (FireControl.AgentFires(x, Entity, target)) + if (FireControl.AgentFires(x, Entity, target, Entity.ItemManager.GameplaySettings.AgentMinHitProbability)) { x.Activate(); } diff --git a/Assets/Scripts/ServerShared/Corporations.cs b/Assets/Scripts/ServerShared/Corporations.cs index 45edba408..c1e69fcca 100644 --- a/Assets/Scripts/ServerShared/Corporations.cs +++ b/Assets/Scripts/ServerShared/Corporations.cs @@ -57,6 +57,12 @@ public class Faction [Inspectable, JsonProperty("bossMusic"), Key(15)] public uint BossMusic; + + // Key 16 is reserved for Relations (operator ruling faction-relations-field). + + // The faction's rules of engagement; null fights as FactionDoctrine.Default. + [Inspectable, JsonProperty("doctrine"), Key(17)] + public FactionDoctrine Doctrine; } [CultDocument("aetheria.namefile", "1"), Inspectable, MessagePackObject] diff --git a/Assets/Scripts/ServerShared/Doctrine.cs b/Assets/Scripts/ServerShared/Doctrine.cs new file mode 100644 index 000000000..40ce188e0 --- /dev/null +++ b/Assets/Scripts/ServerShared/Doctrine.cs @@ -0,0 +1,74 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using GameCult.Caching; +using MessagePack; +using Newtonsoft.Json; + +// What lets a flight open fire. The flight reads it per track; nothing else does. +public enum EngageOn +{ + // Any hostile the flight's members can see. + Detection, + // A hostile whose gear one member has resolved (EntityInfoGathered at or past TargetGearInfoThreshold). + Identified, + // Only a hostile that a member holds a hostile IFF override on (a grudge). + Provoked, + // A hostile whose presence this faction's zone does not permit. + Trespass, + // Never, not even for a grudge. + Never +} + +// A faction's rules of engagement (operator ruling faction-doctrine-typed): flight-level fields plus one RoleDoctrine +// per role. Keys 5-8 are reserved for faction-play-2, keys 9-10 for faction-play-3. A faction with no doctrine gets +// Default, which fights as the agents did before doctrine existed. +[MessagePackObject, JsonObject(MemberSerialization.OptIn)] +public class FactionDoctrine +{ + [Inspectable, JsonProperty("engageOn"), Key(0)] + public EngageOn EngageOn; + + // Seconds between a flight hailing a track and opening fire on it. Zero fires at once, with no hail. + [Inspectable, JsonProperty("grace"), Key(1)] + public float Grace; + + [Inspectable, JsonProperty("hail"), Key(2)] + public string Hail; + + // A hailed track slower than this at the end of the grace counts as complying and is followed, not engaged. + // Zero means nothing complies. + [Inspectable, JsonProperty("complySpeed"), Key(3)] + public float ComplySpeed; + + [Inspectable, JsonProperty("combatant"), Key(4)] + public RoleDoctrine Combatant; + + public static FactionDoctrine Default(GameplaySettings settings) => new FactionDoctrine + { + EngageOn = EngageOn.Detection, + Combatant = new RoleDoctrine + { + RangeExponent = settings.AgentRangeExponent, + MinHitProbability = settings.AgentMinHitProbability + } + }; +} + +// How one role fights. Combatant is the only role so far. +[MessagePackObject, JsonObject(MemberSerialization.OptIn)] +public class RoleDoctrine +{ + // Bias of the preferred engagement range towards longer ranges (CombatState's optimum range). + [Inspectable, JsonProperty("rangeExponent"), Key(0)] + public float RangeExponent; + + // A weapon fires only when its hit probability reaches this. + [Inspectable, JsonProperty("minHitProbability"), Key(1)] + public float MinHitProbability; + + // Distance a flight holds from a track it is hailing or following. Zero means 1.1 x the member's longest weapon range. + [Inspectable, JsonProperty("holdStandoff"), Key(2)] + public float HoldStandoff; +} diff --git a/Assets/Scripts/ServerShared/Doctrine.cs.meta b/Assets/Scripts/ServerShared/Doctrine.cs.meta new file mode 100644 index 000000000..40a273709 --- /dev/null +++ b/Assets/Scripts/ServerShared/Doctrine.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3f9a1c5e7b2d4e68a0c4d1b9e7f25a31 diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 60b84ce87..e7a4b853f 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -814,6 +814,9 @@ public abstract class Entity else _iffOverrides.Remove(other); } + // The override this entity holds on another, or null when it holds none (the derived rule decides). + public bool? IffOverride(Entity other) => _iffOverrides.TryGetValue(other, out var hostile) ? hostile : (bool?) null; + public bool IsHostileTo(Entity other, bool recursive = false) { // An override decides only the stance of the entity that holds it. The recursive=true calls diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 50b8a3b7f..f90e41ae3 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -101,12 +101,13 @@ public static class FireControl // target is designated, whatever the arc: the round is sent out of arc on purpose (Solve). An agent never // fires a fused weapon at nothing -- no target, or one it holds no valid data on, is no reason to fish -- and // never one Solve refuses: a refused weapon is not a weapon it can use. - public static bool AgentFires(Weapon weapon, Entity shooter, Entity target) + // The hit-probability threshold is the shooter's doctrine (RoleDoctrine.MinHitProbability), or the turret's global. + public static bool AgentFires(Weapon weapon, Entity shooter, Entity target, float minHitProbability) { // A mine layer lays only at a designated target in range, never at nothing and never on a hit probability. if (IsMineLayer(weapon)) return Solve(weapon, weapon.Item, shooter, target).Designated; if (FuseOf(weapon.Item, out _) == null) - return HitProbability(weapon, shooter, target) >= shooter.ItemManager.GameplaySettings.AgentMinHitProbability; + return HitProbability(weapon, shooter, target) >= minHitProbability; var solution = Solve(weapon, weapon.Item, shooter, target); return solution.Designated && solution.Outcome != FireOutcome.Refused; } diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 75f278e34..1ad7888fb 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -38,6 +38,11 @@ public class Zone private ItemManager _itemManager; private double _time; public List Agents = new List(); + // One flight per faction, created on its first piloted join; unaffiliated ships share Unaffiliated. A flight owns + // what its members target and do (Agents/Flight.cs). + private static readonly Faction Unaffiliated = new Faction(); + private readonly Dictionary _flights = new Dictionary(); + public IEnumerable Flights => _flights.Values; // Cut 2 (docs/mining-cut.md): chunk wear, keyed by chunk. Zone.Wear is the only writer; ChunkExists and // ChunkRadius are the only readers besides the pack/unpack round trip below. @@ -106,6 +111,16 @@ public class Zone // ObserveAdd fires for all of them. Forbidden writer: no presentation may remove an entity from Zone. Entities.ObserveAdd().Subscribe(add => add.Value.Death.Subscribe(_ => { Entities.Remove(add.Value); add.Value.Deactivate(); })); + // Leaving Entities, by death, docking or a transfer, is leaving the zone's pilots: the agent and its flight + // membership go with the ship, so nothing keeps flying a hull that is not here. + Entities.ObserveRemove().Subscribe(remove => + { + var agent = Agents.FirstOrDefault(a => a.Ship == remove.Value); + if (agent == null) return; + Agents.Remove(agent); + foreach (var flight in _flights.Values) flight.Leave(agent); + }); + Targets.Add(new EntityTargets()); foreach (var orbit in pack.Orbits) @@ -173,10 +188,13 @@ public class Zone private Agent CreateAgent(Ship ship) { - var agent = new Minion(ship); + var key = ship.Faction ?? Unaffiliated; + if (!_flights.TryGetValue(key, out var flight)) + _flights[key] = flight = new Flight(ship.Faction?.Doctrine, _itemManager.GameplaySettings); + var agent = new Minion(ship, flight.Combatant); var task = new PatrolOrbitsTask(); task.Circuit = Orbits.OrderBy(_ => _itemManager.Random.NextFloat()).Take(4).Select(x => x.Key).ToArray(); - agent.Task = task; + flight.Join(agent, task); return agent; } @@ -218,7 +236,8 @@ public class Zone orbit.Value.Velocity = (orbit.Value.Position - orbit.Value.PreviousPosition) / deltaTime; } - foreach(var agent in Agents) + foreach (var flight in _flights.Values) flight.Update(deltaTime); + foreach(var agent in Agents.ToArray()) agent.Update(deltaTime); foreach (var entity in Entities.ToArray()) entity.Update(deltaTime); diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 60bc55e00..49a319e56 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -273,7 +273,7 @@ public sealed class FireControlPerWeaponTests : IDisposable var r = Build(SideThenForward); var group = r.Shooter.WeaponGroups.Single(g => g.weapons.Count == 2).weapons; Assert.True(ReferenceEquals(group[0], r.Guns[0].Weapon), "fixture: the side mount leads the group"); - r.Zone.Agents.Add(new Minion(r.Shooter)); + r.Zone.Agents.Add(new Minion(r.Shooter, FactionDoctrine.Default(r.Shooter.ItemManager.GameplaySettings).Combatant)); r.Zone.Update(1f); r.Zone.Update(1f); @@ -288,7 +288,7 @@ public sealed class FireControlPerWeaponTests : IDisposable public void CombatFiresAFusedWeaponAtADesignatedTargetOutOfArc() { var r = Build(SideThenForward, fuse: WeaponFuse.Proximity, blast: 4f); - r.Zone.Agents.Add(new Minion(r.Shooter)); + r.Zone.Agents.Add(new Minion(r.Shooter, FactionDoctrine.Default(r.Shooter.ItemManager.GameplaySettings).Combatant)); r.Zone.Update(1f); r.Zone.Update(1f); @@ -328,7 +328,7 @@ public sealed class FireControlPerWeaponTests : IDisposable if (reason == Withheld.NoTarget) target = null; if (reason == Withheld.NotVisible) r.Shooter.VisibleEntities.Remove(r.Target); - Assert.False(FireControl.AgentFires(r.Gun.Weapon, r.Shooter, target)); + Assert.False(FireControl.AgentFires(r.Gun.Weapon, r.Shooter, target, r.Shooter.ItemManager.GameplaySettings.AgentMinHitProbability)); } [Fact] @@ -337,7 +337,7 @@ public sealed class FireControlPerWeaponTests : IDisposable // Accuracy 0 prices every direct hit at zero; the fused weapon is unmoved. var r = Build(SideMount, fuse: WeaponFuse.Proximity, blast: 4f, accuracy: 0f); - Assert.True(FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target)); + Assert.True(FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target, r.Shooter.ItemManager.GameplaySettings.AgentMinHitProbability)); } // A weapon that fires at what it hits keeps the AgentMinHitProbability threshold, per weapon. @@ -351,7 +351,7 @@ public sealed class FireControlPerWeaponTests : IDisposable var p = FireControl.HitProbability(r.Gun.Weapon, r.Shooter, r.Target); Assert.True(p > .3f && p < .7f, $"fixture: the shot must price near the accuracy, priced {p}"); - Assert.Equal(fires, FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target)); + Assert.Equal(fires, FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target, r.Shooter.ItemManager.GameplaySettings.AgentMinHitProbability)); } // ==== Ruling 2: the HUD forecast is Fire's own decision ==== @@ -566,7 +566,7 @@ public sealed class FireControlPerWeaponTests : IDisposable { var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range, targetRange: 20f); - Assert.Equal(fires, FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target)); + Assert.Equal(fires, FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target, r.Shooter.ItemManager.GameplaySettings.AgentMinHitProbability)); } // Combat's group pick counts only weapons that can fire: the refused gun's group out-damages the decoy's, so a @@ -577,7 +577,7 @@ public sealed class FireControlPerWeaponTests : IDisposable { var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: 29.5f, targetRange: 20f, decoy: true); Assert.True(r.Gun.Weapon.RangeDamagePerSecond(20f) > r.Decoy.Weapon.RangeDamagePerSecond(20f), "fixture: the refused gun is the stronger group"); - r.Zone.Agents.Add(new Minion(r.Shooter)); + r.Zone.Agents.Add(new Minion(r.Shooter, FactionDoctrine.Default(r.Shooter.ItemManager.GameplaySettings).Combatant)); r.Zone.Update(1f); r.Zone.Update(1f); @@ -594,7 +594,7 @@ public sealed class FireControlPerWeaponTests : IDisposable { var r = Build(range: 10f, targetRange: 20f, decoy: true); Assert.True(r.Gun.Weapon.RangeDamagePerSecond(20f) > r.Decoy.Weapon.RangeDamagePerSecond(20f), "fixture: the short gun is the stronger group"); - r.Zone.Agents.Add(new Minion(r.Shooter)); + r.Zone.Agents.Add(new Minion(r.Shooter, FactionDoctrine.Default(r.Shooter.ItemManager.GameplaySettings).Combatant)); r.Zone.Update(1f); r.Zone.Update(1f); @@ -611,7 +611,7 @@ public sealed class FireControlPerWeaponTests : IDisposable var r = Build(range: 60f, targetRange: 60f); // The agent is driven directly, not through the zone, so the shooter stays exactly at max range: the first // update leaves the root state for combat, the second is combat deciding. - var agent = new Minion(r.Shooter); + var agent = new Minion(r.Shooter, FactionDoctrine.Default(r.Shooter.ItemManager.GameplaySettings).Combatant); agent.Update(.01f); agent.Update(.01f); @@ -630,8 +630,33 @@ public sealed class FireControlPerWeaponTests : IDisposable var atThreshold = Build(accuracy: .5f, minHit: price); var overThreshold = Build(accuracy: .5f, minHit: MathF.BitIncrement(price)); - Assert.True(FireControl.AgentFires(atThreshold.Gun.Weapon, atThreshold.Shooter, atThreshold.Target)); - Assert.False(FireControl.AgentFires(overThreshold.Gun.Weapon, overThreshold.Shooter, overThreshold.Target)); + Assert.True(FireControl.AgentFires(atThreshold.Gun.Weapon, atThreshold.Shooter, atThreshold.Target, atThreshold.Shooter.ItemManager.GameplaySettings.AgentMinHitProbability)); + Assert.False(FireControl.AgentFires(overThreshold.Gun.Weapon, overThreshold.Shooter, overThreshold.Target, overThreshold.Shooter.ItemManager.GameplaySettings.AgentMinHitProbability)); + } + + // The threshold is the doctrine's, passed in: the settings global (zero here) decides nothing for a pilot. Kills: a + // reintroduced read of AgentMinHitProbability inside AgentFires. + [Fact] + public void TheDoctrinesThresholdDecidesFire() + { + var r = Build(accuracy: .5f, minHit: 0f); + var price = FireControl.HitProbability(r.Gun.Weapon, r.Shooter, r.Target); + Assert.True(price > 0f); + + Assert.True(FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target, price)); + Assert.False(FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target, MathF.BitIncrement(price))); + } + + // A long and a short weapon: the exponent biases the preferred range outward. Kills: the optimum read from the + // settings global instead of the member's RoleDoctrine. + [Fact] + public void TheDoctrinesExponentMovesTheOptimumRange() + { + var r = Build(range: 400f, decoy: true); + float Optimum(float exponent) => + new CombatState(new Minion(r.Shooter, new RoleDoctrine { RangeExponent = exponent, MinHitProbability = .2f })).OptimumRange; + + Assert.True(Optimum(0f) < Optimum(1f)); } // A round refused on a Range that fell between the trigger and the round is still free: the burst is paid diff --git a/tests/Aetheria.Shared.Tests/FireControlTests.cs b/tests/Aetheria.Shared.Tests/FireControlTests.cs index a6d40a089..6dd66fbcb 100644 --- a/tests/Aetheria.Shared.Tests/FireControlTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlTests.cs @@ -211,7 +211,7 @@ public sealed class FireControlTests : IDisposable // decision at all (Cut 1's regression), not AgentMinHitProbability tuning -- zero the threshold so an // unaided shot still counts as "worth it." items.GameplaySettings.AgentMinHitProbability = 0f; - zone.Agents.Add(new Minion(shooter)); + zone.Agents.Add(new Minion(shooter, FactionDoctrine.Default(shooter.ItemManager.GameplaySettings).Combatant)); return (shooter, target, zone); } diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs new file mode 100644 index 000000000..c049c9d43 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -0,0 +1,332 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using CultMath; +using GameCult.Caching; +using UniRx; +using Xunit; + +// The flight (Agents/Flight.cs) is the only writer of its members' Target and Task. These tests drive it on the +// IffAndCombatTests world. Perception is the sensors' output (VisibleEnemies, EntityInfoGathered), which +// IffAndCombatTests covers separately, so the tests set it directly; a grudge is a real IFF override. +public sealed class FlightTests : IDisposable +{ + private readonly IffAndCombatTests _world = new IffAndCombatTests(); + private readonly ItemManager _items; + private readonly Zone _zone; + + public FlightTests() => (_items, _zone) = _world.BuildWorld(); + + public void Dispose() => _world.Dispose(); + + private static readonly Faction Hostile = new Faction { Name = "Hostile" }; + + private static Faction WithDoctrine(EngageOn on, float grace = 0, string hail = null, float comply = 0) => new Faction + { + Name = "Escort", + Doctrine = new FactionDoctrine + { + EngageOn = on, Grace = grace, Hail = hail, ComplySpeed = comply, + Combatant = new RoleDoctrine { RangeExponent = .25f, MinHitProbability = .2f } + } + }; + + private Ship Pilot(Faction faction, float x = 0) + { + var ship = _world.NewShip(_items, _zone, faction, piloted: true); + ship.Position = new float3(x, 0, 0); + return ship; + } + + private Ship Track(float x = 100) + { + var track = _world.NewShip(_items, _zone, Hostile); + track.Position = new float3(x, 0, 0); + return track; + } + + private static void See(Ship member, Entity track) + { + member.EntityInfoGathered[track] = 1; + member.VisibleEnemies.Add(track); + } + + private void Tick(float dt) + { + foreach (var flight in _zone.Flights.ToArray()) flight.Update(dt); + } + + [Fact] + public void ADefaultDoctrineEngagesTheVisibleEnemyAsMinionDid() + { + var member = Pilot(new Faction { Name = "Plain" }); + var track = Track(); + See(member, track); + + Tick(.1f); + Assert.Same(track, member.Target.Value.Entity); + + member.VisibleEnemies.Remove(track); + Tick(.1f); + Assert.True(member.Target.Value.IsNone); + Assert.IsType(_zone.Agents.Single().Task); + } + + [Fact] + public void OnlyTheFlightWritesTheTarget() + { + var member = Pilot(new Faction { Name = "Plain" }); + See(member, Track()); + + Assert.True(member.Target.Value.IsNone); + Tick(.1f); + Assert.False(member.Target.Value.IsNone); + } + + [Fact] + public void IdentifiedWaitsForTheGearTier() + { + _items.GameplaySettings.TargetGearInfoThreshold = .8f; + var faction = WithDoctrine(EngageOn.Identified); + var a = Pilot(faction); + var b = Pilot(faction, 10); + var track = Track(); + See(a, track); + See(b, track); + a.EntityInfoGathered[track] = .7f; + b.EntityInfoGathered[track] = .7f; + + Tick(.1f); + Assert.True(a.Target.Value.IsNone); + Assert.True(b.Target.Value.IsNone); + + b.EntityInfoGathered[track] = .9f; + Tick(.1f); + Assert.Same(track, a.Target.Value.Entity); + Assert.Same(track, b.Target.Value.Entity); + } + + [Fact] + public void ProvokedEngagesOnlyTheGrudgedTrack() + { + var member = Pilot(WithDoctrine(EngageOn.Provoked)); + var calm = Track(80); + var grudged = Track(120); + See(member, calm); + See(member, grudged); + member.SetIff(grudged, true); + + Tick(.1f); + + Assert.Same(grudged, member.Target.Value.Entity); + } + + [Fact] + public void NeverIgnoresEvenAGrudge() + { + var member = Pilot(WithDoctrine(EngageOn.Never)); + var track = Track(); + See(member, track); + member.SetIff(track, true); + + Tick(.1f); + + Assert.True(member.Target.Value.IsNone); + } + + [Fact] + public void GraceHailsOnceFromTheNearestThenEngages() + { + var faction = WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold."); + var near = Pilot(faction, 90); + var far = Pilot(faction, 0); + var track = Track(100); + See(near, track); + See(far, track); + + Tick(.1f); + + Assert.True(near.Messages.ContainsKey("Hold.")); + Assert.False(far.Messages.ContainsKey("Hold.")); + Assert.True(near.Target.Value.IsNone && far.Target.Value.IsNone); + Assert.All(_zone.Agents, a => Assert.IsType(a.Task)); + + Tick(4.8f); + Assert.True(near.Target.Value.IsNone && far.Target.Value.IsNone); + + Tick(.4f); + Assert.Same(track, near.Target.Value.Entity); + Assert.Same(track, far.Target.Value.Entity); + } + + [Fact] + public void ASlowTrackComplies() + { + var faction = WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold.", comply: 10); + var member = Pilot(faction); + var track = Track(); + See(member, track); + + Tick(.1f); + Tick(5.1f); + Assert.True(member.Target.Value.IsNone); + Assert.IsType(_zone.Agents.Single().Task); + + member.Messages.Clear(); + track.Velocity = new float2(20, 0); + Tick(.1f); + Assert.Same(track, member.Target.Value.Entity); + Assert.Empty(member.Messages); + } + + [Fact] + public void AGrudgeSkipsTheGrace() + { + var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold.")); + var track = Track(); + See(member, track); + + Tick(.1f); + Assert.True(member.Target.Value.IsNone); + + member.SetIff(track, true); + Tick(.1f); + Assert.Same(track, member.Target.Value.Entity); + } + + [Fact] + public void FollowClosesWhenFarAndOpensWhenNear() + { + var far = Pilot(null); + var near = Pilot(null); + var anchor = Track(); + + float TurnAfterFollowing(Ship ship, float3 anchorAt) + { + anchor.Position = anchorAt; + var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) + { Task = new FollowTask { Anchor = anchor, Standoff = 100 } }; + minion.Update(.1f); + minion.Update(.1f); + return ship.Turn; + } + + // The hull faces +z. Anchor 300 ahead: the desired velocity is dead ahead, no turn. Anchor 20 ahead: inside the + // standoff, the desired velocity is astern, and the pilot turns to it. + Assert.Equal(0, TurnAfterFollowing(far, new float3(0, 0, 300)), 3); + Assert.NotEqual(0, TurnAfterFollowing(near, new float3(0, 0, 20)), 3); + } + + [Fact] + public void ATaskChangeLeavesThePatrolSubstate() + { + var ship = Pilot(null); + var anchor = Track(); + var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) + { Task = new PatrolOrbitsTask { Circuit = new CultRecordKey[1] } }; + for (var i = 0; i < 4; i++) minion.Update(.1f); + Assert.IsType(CurrentState(minion)); + + minion.Task = new FollowTask { Anchor = anchor, Standoff = 50 }; + minion.Update(.1f); + + Assert.IsType(CurrentState(minion)); + } + + private static BaseState CurrentState(Agent agent) => + (BaseState) typeof(Agent).GetField("_currentState", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(agent); + + [Fact] + public void RemovalTakesTheAgentAndTheFlightMember() + { + var faction = new Faction { Name = "Plain" }; + var dies = Pilot(faction); + var leaves = Pilot(faction, 10); + var stays = Pilot(faction, 20); + var player = _world.NewShip(_items, _zone, faction, isPlayerShip: true); + var flight = _zone.Flights.Single(); + Assert.Equal(3, flight.Count); + + dies.Hull.Durability = 0; + dies.HullDamage.OnNext(1f); + _zone.Entities.Remove(leaves); + + Assert.Equal(new[] { stays }, _zone.Agents.Select(a => a.Ship)); + Assert.Equal(1, flight.Count); + Assert.DoesNotContain(_zone.Agents, a => a.Ship == player); + } + + private static string TempCatalog() => Path.Combine(Path.GetTempPath(), "aetheria-doctrine-" + Guid.NewGuid().ToString("N") + ".cc"); + + [Fact] + public void TheDoctrineRoundTrips() + { + var path = TempCatalog(); + try + { + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(new Faction + { + Name = "Armed", + Doctrine = new FactionDoctrine + { + EngageOn = EngageOn.Trespass, Grace = 6, Hail = "Hold.", ComplySpeed = 10, + Combatant = new RoleDoctrine { RangeExponent = .05f, MinHitProbability = .15f, HoldStandoff = 40 } + } + }); + cache.Upsert(new Faction { Name = "Bare" }); + cache.FlushAsync().Wait(); + } + + using (var cache = AetheriaStores.Open(path)) + { + var armed = cache.GetAll().Single(f => f.Name == "Armed").Doctrine; + Assert.Equal(EngageOn.Trespass, armed.EngageOn); + Assert.Equal(6f, armed.Grace); + Assert.Equal("Hold.", armed.Hail); + Assert.Equal(10f, armed.ComplySpeed); + Assert.Equal(.05f, armed.Combatant.RangeExponent); + Assert.Equal(.15f, armed.Combatant.MinHitProbability); + Assert.Equal(40f, armed.Combatant.HoldStandoff); + Assert.Null(cache.GetAll().Single(f => f.Name == "Bare").Doctrine); + } + } + finally { File.Delete(path); } + } + + [Fact] + public void TheCatalogCommandNamesAMissingFaction() + { + var path = TempCatalog(); + try + { + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + foreach (var name in new[] { "Zhestokost", "Lucent Media", "Aeronautics Unlimited" }) cache.Upsert(new Faction { Name = name }); + cache.FlushAsync().Wait(); + } + + var output = new StringWriter(); + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + DoctrineCatalog.Run(cache, apply: true, output); + cache.FlushAsync().Wait(); + } + + Assert.Contains("Pirates: no Faction record, doctrine not written", output.ToString()); + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + Assert.All(cache.GetAll(), f => Assert.NotNull(f.Doctrine)); + Assert.Equal(EngageOn.Trespass, cache.GetAll().Single(f => f.Name == "Zhestokost").Doctrine.EngageOn); + cache.Upsert(new Faction { Name = "Pirates" }); + DoctrineCatalog.Run(cache, apply: true, new StringWriter()); + Assert.Equal(8f, cache.GetAll().Single(f => f.Name == "Pirates").Doctrine.Grace); + } + } + finally { File.Delete(path); } + } +} diff --git a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs index e77bfad51..0f7e4339b 100644 --- a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs +++ b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs @@ -48,7 +48,7 @@ public sealed class IffAndCombatTests : IDisposable // A world with one empty zone (no faction/security machinery engaged: GalaxyZone.Owner is null and // every test ship is non-player, so PresencePermitted stays at its default true and derived hostility // between non-owner factions is always false). Individual tests layer overrides/factions on top. - private (ItemManager items, Zone zone) BuildWorld() + internal (ItemManager items, Zone zone) BuildWorld() { _openCache = AetheriaStores.Open(Catalog, catalogWritable: true); var ledger = new ProvenanceLedger(); @@ -69,7 +69,7 @@ public sealed class IffAndCombatTests : IDisposable TargetDetectionInfoThreshold = .5f }; - private Ship NewShip(ItemManager items, Zone zone, Faction faction = null, bool isPlayerShip = false, bool equipGun = false) + internal Ship NewShip(ItemManager items, Zone zone, Faction faction = null, bool isPlayerShip = false, bool equipGun = false, bool piloted = false) { var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Skiff")); var hull = new EquippableItem { Data = hullRef, Durability = 1, Lot = 1 }; @@ -82,14 +82,19 @@ public sealed class IffAndCombatTests : IDisposable Assert.True(ship.TryEquip(gun, HardpointCell)); } - zone.Entities.Add(ship); - ship.Activate(); + // A piloted ship joins as every piloted admission does (Zone.Admit), and so gets its agent and its flight. + if (piloted) zone.Admit(ship, true); + else + { + zone.Entities.Add(ship); + ship.Activate(); + } return ship; } // Marks entity `observer` as having detected `target`, the same way Sensor ramps EntityInfoGathered // past TargetDetectionInfoThreshold in the real game. - private static void Detect(Entity observer, Entity target) => observer.EntityInfoGathered[target] = 1; + internal static void Detect(Entity observer, Entity target) => observer.EntityInfoGathered[target] = 1; [Fact] public void OverrideDecidesOutrightOverDerivedRule() diff --git a/tests/Aetheria.Shared.Tests/MineTests.cs b/tests/Aetheria.Shared.Tests/MineTests.cs index ba3c0f0d5..cd2a4c733 100644 --- a/tests/Aetheria.Shared.Tests/MineTests.cs +++ b/tests/Aetheria.Shared.Tests/MineTests.cs @@ -623,9 +623,9 @@ public sealed class MineTests : IDisposable } lab.Zone.Update(1f); - Assert.False(FireControl.AgentFires(lab.Weapon, lab.Layer, null)); - Assert.False(FireControl.AgentFires(lab.Weapon, lab.Layer, far)); - Assert.True(FireControl.AgentFires(lab.Weapon, lab.Layer, near)); + Assert.False(FireControl.AgentFires(lab.Weapon, lab.Layer, null, lab.Layer.ItemManager.GameplaySettings.AgentMinHitProbability)); + Assert.False(FireControl.AgentFires(lab.Weapon, lab.Layer, far, lab.Layer.ItemManager.GameplaySettings.AgentMinHitProbability)); + Assert.True(FireControl.AgentFires(lab.Weapon, lab.Layer, near, lab.Layer.ItemManager.GameplaySettings.AgentMinHitProbability)); Assert.False(FireControl.Refuses(lab.Weapon, lab.Layer)); } @@ -667,7 +667,7 @@ public sealed class MineTests : IDisposable lab.Zone.Update(1f); Assert.False(FireControl.Refuses(lab.Weapon, lab.Layer)); - Assert.True(FireControl.AgentFires(lab.Weapon, lab.Layer, near)); + Assert.True(FireControl.AgentFires(lab.Weapon, lab.Layer, near, lab.Layer.ItemManager.GameplaySettings.AgentMinHitProbability)); var mine = LayOne(lab); Assert.Equal(150f, mine.BlastRadius); } diff --git a/tools/AetherDb/DoctrineCatalog.cs b/tools/AetherDb/DoctrineCatalog.cs new file mode 100644 index 000000000..5b0413d62 --- /dev/null +++ b/tools/AetherDb/DoctrineCatalog.cs @@ -0,0 +1,69 @@ +using System; +using System.IO; +using System.Linq; +using GameCult.Caching; + +// Authors the starting FactionDoctrine of the four factions the demo cast uses (aetheria-release, faction-play-1). +// Tuning is a starting point; the operator adjusts it after play. A faction with no Faction record is named, not +// created: the Pirates' record is owed by follow-up pirates-catalog-record, and re-running apply writes them once it lands. +public static class DoctrineCatalog +{ + private static readonly (string faction, FactionDoctrine doctrine)[] Table = + { + ("Zhestokost", new FactionDoctrine + { + EngageOn = EngageOn.Trespass, Grace = 6f, Hail = "Cut thrust and hold for inspection.", ComplySpeed = 10f, + Combatant = new RoleDoctrine { RangeExponent = .05f, MinHitProbability = .05f } + }), + ("Lucent Media", new FactionDoctrine + { + EngageOn = EngageOn.Detection, Grace = 2f, Hail = "You're live.", + Combatant = new RoleDoctrine { RangeExponent = .25f, MinHitProbability = .3f } + }), + ("Aeronautics Unlimited", new FactionDoctrine + { + EngageOn = EngageOn.Detection, + Combatant = new RoleDoctrine { RangeExponent = .25f, MinHitProbability = .05f } + }), + ("Pirates", new FactionDoctrine + { + EngageOn = EngageOn.Detection, Grace = 8f, Hail = "Drop your cargo and you can go.", + Combatant = new RoleDoctrine { RangeExponent = .5f, MinHitProbability = .15f } + }), + }; + + // Dry run unless apply, as the other catalog commands. The cache must be writable for apply. + public static int Run(CultCache cache, bool apply, TextWriter output) + { + var factions = cache.GetAll().ToArray(); + var changed = new System.Collections.Generic.List(); + foreach (var (name, doctrine) in Table) + { + var faction = factions.FirstOrDefault(f => string.Equals(f.Name, name, StringComparison.OrdinalIgnoreCase)); + if (faction == null) + { + output.WriteLine($"{name}: no Faction record, doctrine not written"); + continue; + } + + output.WriteLine($"{name}: engage on {doctrine.EngageOn}, grace {doctrine.Grace:0.##}s, hail \"{doctrine.Hail}\", " + + $"comply {doctrine.ComplySpeed:0.##}, range exponent {doctrine.Combatant.RangeExponent:0.##}, " + + $"min hit {doctrine.Combatant.MinHitProbability:0.##}"); + faction.Doctrine = doctrine; + changed.Add(faction); + } + + if (!apply) + { + output.WriteLine("\nDry run. Pass \"apply\" to author them."); + return 0; + } + + cache.Commit(batch => + { + foreach (var faction in changed) batch.Upsert(typeof(Faction), faction, cache.RefOf(faction).Key); + }); + output.WriteLine($"\nAuthored {changed.Count} doctrines in Aetheria.cc"); + return 0; + } +} diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index c65f4c3f2..d0ed0fbfc 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -38,8 +38,9 @@ public static class Program case "targeting-catalog-6d": return TargetingCatalog6d(args.Contains("apply")); case "ship-authoring": return ShipAuthoringCommands.Run(args.Skip(1).ToArray()); case "field-kinds": return FieldKindsCatalog(args.Contains("apply")); + case "doctrine-catalog": return DoctrineCatalog.Run(AetherDb.Open(catalogWritable: args.Contains("apply")).Cache, args.Contains("apply"), Console.Out); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], doctrine-catalog [apply]"); return 1; } } @@ -659,7 +660,7 @@ public static class Program { var db = AetherDb.Open(); var products = db.Cache.GetAll().ToArray(); - Console.WriteLine($"{"faction",-26} {"short",-13} {"geonames",-22} {"boss hull",-14} {"influence",-9} products"); + Console.WriteLine($"{"faction",-26} {"short",-13} {"geonames",-22} {"boss hull",-14} {"influence",-9} {"doctrine",-10} products"); var broken = 0; foreach (var faction in db.Cache.GetAll().OrderBy(f => f.Name)) { @@ -669,7 +670,7 @@ public static class Program ? "none" : db.Cache.Get(faction.BossHull)?.Name ?? "DANGLING"; if (geonames == "UNSET" || boss == "DANGLING") broken++; - Console.WriteLine($"{faction.Name,-26} {faction.ShortName,-13} {geonames,-22} {boss,-14} {faction.InfluenceDistance,-9} " + + Console.WriteLine($"{faction.Name,-26} {faction.ShortName,-13} {geonames,-22} {boss,-14} {faction.InfluenceDistance,-9} {(faction.Doctrine == null ? "none" : faction.Doctrine.EngageOn.ToString()),-10} " + products.Count(p => p.Manufacturer.Key.Equals(key))); } Console.WriteLine($"\n{broken} factions carry a link that would break generation");