diff --git a/Assets/Scripts/ServerShared/Agents/Agent.cs b/Assets/Scripts/ServerShared/Agents/Agent.cs index eb74818ba..03a76436a 100644 --- a/Assets/Scripts/ServerShared/Agents/Agent.cs +++ b/Assets/Scripts/ServerShared/Agents/Agent.cs @@ -23,6 +23,9 @@ public class Agent private VelocityLimit _velocityLimit; public AgentTask Task { get; set; } + // What this pilot's side knows of other ships. A ship outside a flight is a side of one: a view over itself, until + // Flight.Join sets the flight's. + public PilotView View { get; internal set; } // Zone-scoped, from 1: the ordinal of this pilot's own dice, set by Zone.CreateAgent as it admits the pilot (0 for // an agent built outside a zone's admission). public uint Id { get; set; } @@ -36,6 +39,7 @@ public class Agent public Agent(Ship ship, RoleDoctrine doctrine) { Ship = ship; + View = new PilotView(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 index 497ac865f..098598e92 100644 --- a/Assets/Scripts/ServerShared/Agents/Flight.cs +++ b/Assets/Scripts/ServerShared/Agents/Flight.cs @@ -3,15 +3,17 @@ using System.Collections.Generic; using System.Linq; using CultMath; using static CultMath.math; +using ContactId = PilotView.ContactId; -// One flight per (zone, faction) of piloted ships; unaffiliated ships form one flight of their own. The flight alone +// One flight per (zone, side) of piloted ships; a side is a faction, or one unaffiliated ship. 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. +// The flight reads other ships only through its side's PilotView (ruling observation-sensors-only): it holds a contact +// as a handle and never as the ship. The picture is the view's: the union of what the members' sensors hold hostile, never a member. +// 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 +// sensors hold (fire control), so the shared picture never lets it fire at what it cannot see. public class Flight { public enum Phase { None, Hailing, Complied, Engaged } @@ -31,7 +33,7 @@ public class Flight private readonly FactionDoctrine _doctrine; private readonly GameplaySettings _settings; private readonly List _members = new List(); - private readonly Dictionary _tracks = new Dictionary(); + 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. @@ -44,58 +46,64 @@ public class Flight } public RoleDoctrine Combatant { get; } + // What this side knows of other ships; every member's Agent.View. + public PilotView View { get; } = new PilotView(); 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 Phase PhaseOf(ContactId track) => _tracks.TryGetValue(track, out var t) ? t.Phase : Phase.None; // The task a member holds when the flight has nothing to follow: its patrol circuit, or the task a scenario stated. public void Join(Agent agent, AgentTask baseline) { _members.Add(new Member { Agent = agent, Baseline = baseline }); + View.Add(agent.Ship); + agent.View = View; agent.Task = baseline; } - public void Leave(Agent agent) => _members.RemoveAll(m => m.Agent == agent); + public void Leave(Agent agent) + { + if (_members.RemoveAll(m => m.Agent == agent) > 0) View.Remove(agent.Ship); + } public void Update(float dt) { _time += dt; - var picture = _members.SelectMany(m => m.Agent.Ship.VisibleEnemies).Distinct().ToList(); + var picture = View.Picture(); foreach (var gone in _tracks.Keys.Where(t => !picture.Contains(t)).ToList()) _tracks.Remove(gone); - foreach (var entity in picture) + foreach (var contact in picture) { - if (!_tracks.TryGetValue(entity, out var track)) _tracks[entity] = track = new Track(); - Advance(entity, track); + if (!_tracks.TryGetValue(contact, out var track)) _tracks[contact] = track = new Track(); + Advance(contact, 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 Grudged(ContactId contact) => _members.Any(m => View.IffOverride(m.Agent.Ship, contact) == true); - private bool Engageable(Entity entity, bool grudge) + private bool Engageable(ContactId contact, 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); + return grudge || _members.Any(m => View.Info(m.Agent.Ship, contact) >= _settings.TargetGearInfoThreshold); case EngageOn.Provoked: return grudge; - case EngageOn.Trespass: return grudge || _members.Any(m => m.Agent.Ship.BelievesTrespassing(entity)); + case EngageOn.Trespass: return grudge || _members.Any(m => View.BelievesTrespassing(m.Agent.Ship, contact)); default: return false; } } - private void Advance(Entity entity, Track track) + private void Advance(ContactId contact, Track track) { if (track.Phase == Phase.Engaged) return; - var grudge = Grudged(entity); - if (!Engageable(entity, grudge)) + var grudge = Grudged(contact); + if (!Engageable(contact, grudge)) { track.Phase = Phase.None; return; @@ -115,56 +123,59 @@ public class Flight { track.Phase = Phase.Hailing; track.Start = _time; - if (!string.IsNullOrEmpty(_doctrine.Hail)) Nearest(entity)?.Agent.Ship.Command.Hail(_doctrine.Hail); + if (!string.IsNullOrEmpty(_doctrine.Hail)) Nearest(contact)?.Agent.Ship.Command.Hail(_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 + track.Phase = _doctrine.ComplySpeed > 0 && length(View.SideTrack(contact).Value.Velocity) < _doctrine.ComplySpeed ? Phase.Complied : Phase.Engaged; break; case Phase.Complied: - if (length(entity.Velocity) >= _doctrine.ComplySpeed) track.Phase = Phase.Engaged; + if (length(View.SideTrack(contact).Value.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 Member Nearest(ContactId contact) + { + var at = View.SideTrack(contact).Value.Position.xz; + return _members.OrderBy(m => lengthsq(m.Agent.Ship.Position.xz - at)).FirstOrDefault(); + } - private void Order(Member member, List picture) + 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(); + var engaged = picture.Where(c => _tracks[c].Phase == Phase.Engaged && View.Sees(ship, c)).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); + var current = View.AppliedTargetOf(ship); + var pick = current != null && engaged.Contains(current.Value) + ? current.Value + : engaged.OrderBy(c => lengthsq(ship.Position.xz - View.SideTrack(c).Value.Position.xz)).First(); + View.Designate(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); + View.Designate(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(); + var held = picture.Where(c => _tracks[c].Phase != Phase.None && View.IffOverride(ship, c) != 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(); + var anyEngaged = held.Any(c => _tracks[c].Phase == Phase.Engaged); + var heldAnchor = (member.Agent.Task as FollowTask)?.Anchor; + var anchor = heldAnchor != null && held.Contains(heldAnchor.Value) + && (!anyEngaged || _tracks[heldAnchor.Value].Phase == Phase.Engaged) + ? heldAnchor.Value + : held.OrderBy(c => _tracks[c].Phase == Phase.Engaged ? 0 : 1) + .ThenBy(c => lengthsq(ship.Position.xz - View.SideTrack(c).Value.Position.xz)).First(); if (!(member.Agent.Task is FollowTask current) || current.Anchor != anchor) member.Agent.Task = new FollowTask { Anchor = anchor, Standoff = Standoff(ship) }; } @@ -172,12 +183,6 @@ public class Flight member.Agent.Task = member.Baseline; } - private static void Aim(Ship ship, Entity target) - { - var wanted = target == null ? TargetRef.None : new TargetRef(target); - if (!ship.Target.Value.Equals(wanted)) ship.Command.Designate(wanted); - } - private float Standoff(Ship ship) { if (Combatant.HoldStandoff > 0) return Combatant.HoldStandoff; diff --git a/Assets/Scripts/ServerShared/Agents/Minion.cs b/Assets/Scripts/ServerShared/Agents/Minion.cs index a1277b264..c95b7cfbb 100644 --- a/Assets/Scripts/ServerShared/Agents/Minion.cs +++ b/Assets/Scripts/ServerShared/Agents/Minion.cs @@ -22,9 +22,9 @@ public class Minion : Agent var combatState = new CombatState(this); _rootState.AddTransition(combatState, - () => (Ship.Command.Designated ?? Ship.Target.Value).Entity != null, null, true, _rootState); + () => View.TargetOf(Ship) != null, null, true, _rootState); combatState.AddTransition(_rootState, - () => (Ship.Command.Designated ?? Ship.Target.Value).Entity == null, null, true, _rootState); + () => View.TargetOf(Ship) == null, null, true, _rootState); } } diff --git a/Assets/Scripts/ServerShared/Agents/PilotView.cs b/Assets/Scripts/ServerShared/Agents/PilotView.cs new file mode 100644 index 000000000..51cacf32f --- /dev/null +++ b/Assets/Scripts/ServerShared/Agents/PilotView.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using CultMath; + +// What one side's brain may know of other ships. A side is a faction, or one unaffiliated ship (brain-one-per-side-r2); +// its view reads only that side's members: their sensors (VisibleEntities, VisibleEnemies, EntityInfoGathered), their +// IFF overrides and reveal books (ruling observation-sensors-only). A brain never holds another ship: it holds a +// ContactId or a RevealId, which only the view that issued it can resolve, so there is no path from a handle to the +// ship's truth that does not pass the sensors. +// +// A contact's Track (position, velocity, facing) exists only while a member tracks it (its VisibleEntities holds it): +// within a zone step nothing moves during the agent phase, so the track equals what the Entity would answer. The +// fire-control readouts call FireControl's own functions with the resolved subject, so the HUD, the player and the +// brains price one model (A-one-sim). Own-ship reads stay the brain's own. +// +// The view enforces its own rules; it does not trust its callers. Every per-member read answers for a member the view +// holds and nothing else, and every per-contact fire-control readout (Intercept, WeaponReveals, Designated, Refuses, +// Fires) answers only while that member itself tracks the contact: a sidemate's track gives a member no aim, no reveals +// and no shot (per-shooter-fire-control). Only Flight builds a view and sets it on its pilots, and the side-of-one view an +// Agent starts with is its own; the constructor and the setter are internal. +public sealed class PilotView +{ + private readonly List _members = new List(); + private static Func _openContact; + private static Func _openReveal; + + internal PilotView(params Ship[] members) => _members.AddRange(members); + + private bool Holds(Ship member) => _members.Contains(member); + private bool Tracks(Ship member, Entity subject) => Holds(member) && subject != null && member.VisibleEntities.Contains(subject); + + internal void Add(Ship member) => _members.Add(member); + internal void Remove(Ship member) => _members.Remove(member); + + // A contact of the view, equal to itself across absences from a member's picture: equality is by reference. + public readonly struct ContactId : IEquatable + { + private readonly Entity _subject; + + static ContactId() => _openContact = id => id._subject; + internal ContactId(Entity subject) => _subject = subject; + + public bool Equals(ContactId other) => ReferenceEquals(_subject, other._subject); + public override bool Equals(object obj) => obj is ContactId other && Equals(other); + public override int GetHashCode() => RuntimeHelpers.GetHashCode(_subject); + public static bool operator ==(ContactId a, ContactId b) => a.Equals(b); + public static bool operator !=(ContactId a, ContactId b) => !a.Equals(b); + } + + // A revealed item of a contact, to aim at; the live item stays behind the handle (ruling contact-reads-book-aim-reads-live). + public readonly struct RevealId : IEquatable + { + private readonly EquippedItem _item; + + static RevealId() => _openReveal = id => id._item; + internal RevealId(EquippedItem item) => _item = item; + + public bool Equals(RevealId other) => ReferenceEquals(_item, other._item); + public override bool Equals(object obj) => obj is RevealId other && Equals(other); + public override int GetHashCode() => RuntimeHelpers.GetHashCode(_item); + public static bool operator ==(RevealId a, RevealId b) => a.Equals(b); + public static bool operator !=(RevealId a, RevealId b) => !a.Equals(b); + } + + public readonly struct Track + { + public readonly float3 Position; + public readonly float2 Velocity; + public readonly float2 Direction; + + public Track(float3 position, float2 velocity, float2 direction) + { + Position = position; + Velocity = velocity; + Direction = direction; + } + } + + // For fixtures, and for the one place the stage states what a pilot already knows (ScenarioStage.Approach seeds an + // anchor handle for a FollowTask before the zone exists: staged knowledge, ruling stage-states-pilot-knowledge). A + // brain never calls this; the sensors, not the handle, decide whether the view answers about the contact. + internal static ContactId Handle(Entity subject) => new ContactId(subject); + + private static Entity Subject(ContactId id) + { + if (_openContact == null) RuntimeHelpers.RunClassConstructor(typeof(ContactId).TypeHandle); + return _openContact(id); + } + + private static EquippedItem Item(RevealId id) + { + if (_openReveal == null) RuntimeHelpers.RunClassConstructor(typeof(RevealId).TypeHandle); + return _openReveal(id); + } + + // The side's contacts: its members' VisibleEnemies in member order, each once, never a member of the side. + public List Picture() + { + var picture = new List(); + var seen = new HashSet(); + foreach (var member in _members) + foreach (var enemy in member.VisibleEnemies) + if (!_members.Any(m => ReferenceEquals(m, enemy)) && seen.Add(enemy)) picture.Add(new ContactId(enemy)); + return picture; + } + + public bool Sees(Ship member, ContactId contact) => Holds(member) && member.VisibleEnemies.Contains(Subject(contact)); + + // The member's own track of the contact, or null while it does not track it. + public Track? TrackOf(Ship member, ContactId contact) + { + var subject = Subject(contact); + if (!Tracks(member, subject)) return null; + return new Track(subject.Position, subject.Velocity, subject.Direction); + } + + // The track of the first member, in order, that tracks the contact (question side-shared-kinematics). + public Track? SideTrack(ContactId contact) + { + foreach (var member in _members) + { + var track = TrackOf(member, contact); + if (track != null) return track; + } + + return null; + } + + public bool? IffOverride(Ship member, ContactId contact) => Holds(member) ? member.IffOverride(Subject(contact)) : null; + + public float? Info(Ship member, ContactId contact) => + Holds(member) && member.EntityInfoGathered.TryGetValue(Subject(contact), out var info) ? info : (float?) null; + + public bool BelievesTrespassing(Ship member, ContactId contact) => Holds(member) && member.BelievesTrespassing(Subject(contact)); + + // The member's pending designation when set, else last step's applied target. + public ContactId? TargetOf(Ship member) + { + if (!Holds(member)) return null; + var subject = (member.Command.Designated ?? member.Target.Value).Entity; + return subject == null ? (ContactId?) null : new ContactId(subject); + } + + public ContactId? AppliedTargetOf(Ship member) + { + if (!Holds(member)) return null; + var subject = member.Target.Value.Entity; + return subject == null ? (ContactId?) null : new ContactId(subject); + } + + // The weapons the member believes the contact has, in the contact's equipment order; Aimable is FireControl.IsRevealed. + // Empty unless the member tracks the contact. + public List<(RevealId Id, EquippableItemData Claimed, bool Aimable)> WeaponReveals(Ship member, ContactId contact) + { + var subject = Subject(contact); + if (!Tracks(member, subject)) return new List<(RevealId, EquippableItemData, bool)>(); + return member.RevealedWeapons(subject) + .Select(r => (new RevealId(r.Item), r.Claimed, FireControl.IsRevealed(member, r.Item))).ToList(); + } + + // The next four answer for a member that tracks the contact: otherwise not designated, refused, no shot, no intercept. + public bool Designated(Ship member, Weapon weapon, ContactId contact) + { + var subject = Subject(contact); + return Tracks(member, subject) && FireControl.Designated(weapon, member, subject, out _); + } + + public bool Refuses(Ship member, Weapon weapon, ContactId contact) + { + var subject = Subject(contact); + return !Tracks(member, subject) || FireControl.Refuses(weapon, member, subject); + } + + public bool Fires(Ship member, Weapon weapon, ContactId contact, float minHitProbability, RevealId? aim) + { + var subject = Subject(contact); + return Tracks(member, subject) && FireControl.AgentFires(weapon, member, subject, minHitProbability, + aim == null ? AimTarget.None : AimTarget.OfItem(Item(aim.Value))); + } + + // Where to point to hit the contact with the weapon: the shared prediction, at the height the contact rides; null + // unless the member tracks the contact. + public float3? Intercept(Ship member, Weapon weapon, ContactId contact) + { + var subject = Subject(contact); + if (!Tracks(member, subject)) return null; + var predicted = FireControl.PredictedIntercept(weapon, member, subject); + predicted.y = subject.RideHeight(predicted.xz); + return predicted; + } + + // Designate the contact, or nothing for null, when that is not already the member's applied target. + public void Designate(Ship member, ContactId? contact) + { + if (!Holds(member)) return; + var wanted = contact == null ? TargetRef.None : new TargetRef(Subject(contact.Value)); + if (!member.Target.Value.Equals(wanted)) member.Command.Designate(wanted); + } + + public void SelectItem(Ship member, RevealId? reveal) + { + if (Holds(member)) member.Command.SelectItem(reveal == null ? null : Item(reveal.Value)); + } +} diff --git a/Assets/Scripts/ServerShared/Agents/PilotView.cs.meta b/Assets/Scripts/ServerShared/Agents/PilotView.cs.meta new file mode 100644 index 000000000..8c9b644f3 --- /dev/null +++ b/Assets/Scripts/ServerShared/Agents/PilotView.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5b0f6c2e9d3a4f71a8c4e1d7b2a63f90 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index 1a062e7be..f8c12baab 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -43,36 +43,39 @@ public class CombatState : BaseState _optimumStale = false; } - // The pilot's own pending designation (its Flight's, this step) when set, else last step's applied target. - var target = (_agent.Ship.Command.Designated ?? _agent.Ship.Target.Value).Entity; + // The pilot's own pending designation (its Flight's, this step) when set, else last step's applied target; and + // only a contact a member tracks has a track to fly on, so an untracked target is no target here. + var view = _agent.View; + var target = view.TargetOf(_agent.Ship); if (target == null) return; + var track = view.TrackOf(_agent.Ship, target.Value); + if (track == null) return; _availableGroups.Clear(); _availableLockingWeapons.Clear(); - var toTarget = target.Position - _agent.Ship.Position; + var toTarget = track.Value.Position - _agent.Ship.Position; var targetDistance = length(toTarget.xz); // Cut 2 (docs/fire-control-cut.md): aim at whichever revealed weapon on the target hits hardest at // this range, or clear the aim point when nothing qualifies -- the same predicate - // (FireControl.IsRevealed) and the same writer (TrySelectTargetItem) the player path uses. Which weapons it + // (the view's Aimable) and the same writer (TrySelectTargetItem) the player path uses. Which weapons it // believes the target has, and how hard each hits, come from its book's claims, not the target's live gear // (aetheria-ew, classify-from-belief; ew-claim-weapon-measure-ruled: the claimed design's rated figures); the // reveal's Item is the aim and stays the truth. - EquippedItem bestTargetItem = null; + PilotView.RevealId? bestTargetItem = null; var bestTargetDps = float.MinValue; - foreach (var reveal in _agent.Ship.RevealedWeapons(target)) + foreach (var reveal in view.WeaponReveals(_agent.Ship, target.Value)) { - if (!FireControl.IsRevealed(_agent.Ship, reveal.Item)) continue; + if (!reveal.Aimable) continue; var dps = reveal.Claimed.Behaviors.OfType().First().RatedRangeDamagePerSecond(targetDistance); if (dps > bestTargetDps) { bestTargetDps = dps; - bestTargetItem = reveal.Item; + bestTargetItem = reveal.Id; } } - _agent.Ship.Command.SelectItem(bestTargetItem); - var aim = bestTargetItem == null ? AimTarget.None : AimTarget.OfItem(bestTargetItem); + view.SelectItem(_agent.Ship, bestTargetItem); for (var i = 0; i < _agent.Ship.WeaponGroups.Length; i++) { @@ -82,8 +85,8 @@ public class CombatState : BaseState // The range test is FireControl's (Designated), and a weapon Solve refuses adds nothing to a group's // damage. if (weapon.Item.Online.Value && - FireControl.Designated(weapon, _agent.Ship, target, out _) && - !FireControl.Refuses(weapon, _agent.Ship, target) && + view.Designated(_agent.Ship, weapon, target.Value) && + !view.Refuses(_agent.Ship, weapon, target.Value) && (weapon is ConstantWeapon || weapon is InstantWeapon instantWeapon && instantWeapon.CanFire)) { @@ -111,11 +114,11 @@ public class CombatState : BaseState } } - var targetRight = target.Direction.Rotate(ItemRotation.Clockwise); + var targetRight = track.Value.Direction.Rotate(ItemRotation.Clockwise); var optimumRangeDelta = abs(_optimumRange - targetDistance); var directionToTarget = normalize(toTarget.xz); var targetPortAlignment = dot(targetRight, directionToTarget); - var targetForeAlignment = dot(-target.Direction, directionToTarget); + var targetForeAlignment = dot(-track.Value.Direction, directionToTarget); var forwardness = saturate(optimumRangeDelta / _agent.Settings.AgentMaxForwardDistance) * _agent.Settings.AgentForwardLerp; if (targetForeAlignment > 0) forwardness = lerp(forwardness, 1, pow(targetPortAlignment, 2)); @@ -128,20 +131,20 @@ public class CombatState : BaseState var testWeapon = _agent.Ship.WeaponGroups[selectedGroup].weapons.First(); if(testWeapon.Velocity > 1) { - // Cut 3 (docs/fire-control-cut.md): the one prediction function, shared with FireControl.Fire's + // Cut 3 (docs/fire-control-cut.md): the one prediction function, shared with the shot's // own snapshot and the roll's deviation measurement -- two separate predictions here and in the // roll would let an AI aim at one point and get judged for missing a different one. - var predictedPosition = FireControl.PredictedIntercept(testWeapon, _agent.Ship, target); - predictedPosition.y = target.RideHeight(predictedPosition.xz); - toTarget = normalize(predictedPosition - _agent.Ship.Position); + // The target is tracked here (the guard above), so the intercept is never null. + var predictedPosition = view.Intercept(_agent.Ship, testWeapon, target.Value); + if (predictedPosition != null) toTarget = normalize(predictedPosition.Value - _agent.Ship.Position); } // Each weapon decides for itself (operator ruling 2026-09-30): a group shares a trigger, not a - // verdict. FireControl.AgentFires is the AI's "worth it" heuristic for a weapon that hits what it + // verdict. The view's Fires is the AI's "worth it" heuristic for a weapon that hits what it // 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, _agent.Doctrine.MinHitProbability, aim)) + if (view.Fires(_agent.Ship, weapon, target.Value, _agent.Doctrine.MinHitProbability, bestTargetItem)) _agent.Ship.Command.Pull(weapon); // A lock pulled above this step counts as firing, as it did when it was activated in place. else if (weapon.Firing || _agent.Ship.Command.Pulls(weapon)) diff --git a/Assets/Scripts/ServerShared/Agents/States/Follow.cs b/Assets/Scripts/ServerShared/Agents/States/Follow.cs index 1bbf3f826..e1701bc6c 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Follow.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Follow.cs @@ -11,14 +11,16 @@ public class FollowState : BaseState { var task = _agent.Task as FollowTask; if (task?.Anchor == null) return; + var anchor = _agent.View.SideTrack(task.Anchor.Value); + if (anchor == null) return; - var toAnchor = task.Anchor.Position.xz - _agent.Ship.Position.xz; + var toAnchor = anchor.Value.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; + var desired = anchor.Value.Velocity + pull * _agent.TopSpeed; _agent.Ship.Command.Aim = float3(direction.x, 0, direction.y); _agent.Accelerate(desired); diff --git a/Assets/Scripts/ServerShared/Agents/Tasks/FollowTask.cs b/Assets/Scripts/ServerShared/Agents/Tasks/FollowTask.cs index 4e09d0a35..946539c6f 100644 --- a/Assets/Scripts/ServerShared/Agents/Tasks/FollowTask.cs +++ b/Assets/Scripts/ServerShared/Agents/Tasks/FollowTask.cs @@ -5,7 +5,7 @@ using MessagePack; // Runtime only, like every agent task. public class FollowTask : AgentTask { - [IgnoreMember] public Entity Anchor; + [IgnoreMember] public PilotView.ContactId? Anchor; [IgnoreMember] public float Standoff; [IgnoreMember] public override TaskType Type => TaskType.Follow; } diff --git a/Assets/Scripts/ServerShared/RunStart.cs b/Assets/Scripts/ServerShared/RunStart.cs index 2e1245bab..20269e84c 100644 --- a/Assets/Scripts/ServerShared/RunStart.cs +++ b/Assets/Scripts/ServerShared/RunStart.cs @@ -55,6 +55,12 @@ public static class RunStart player.SetIff(placed.Entity, hostile); arena.Admit(placed.Entity, placed.Piloted, placed.Task); } + + // A start state includes what a pilot knows (ruling stage-states-pilot-knowledge): its anchor is tracked from the first + // step, by the sensors' own path (info gathered past the detection threshold), so they can lose it like any track. + foreach (var placed in stage.Placed) + if (placed.Knows != null && placed.Piloted) + placed.Entity.EntityInfoGathered[placed.Knows] = (itemManager.GameplaySettings.TargetDetectionInfoThreshold + 1f) / 2f; return new Staged { Player = player, Credits = stage.Credits, Entities = stage.Placed.Select(placed => placed.Entity).ToList() }; } diff --git a/Assets/Scripts/ServerShared/ScenarioStage.cs b/Assets/Scripts/ServerShared/ScenarioStage.cs index c9531e60f..453ede8ec 100644 --- a/Assets/Scripts/ServerShared/ScenarioStage.cs +++ b/Assets/Scripts/ServerShared/ScenarioStage.cs @@ -50,6 +50,9 @@ public sealed class ScenarioStage public ScenarioStance Stance; public bool Piloted; public AgentTask Task; + // What the pilot already knows at the start: an entity its sensors hold a track of (Approach). Seeded through the + // sensors' own state at admission, so they may lose it honestly. + public Entity Knows; } // A hull fitted slot by slot: each slot a design or a product, the hull cell it goes in, and its rotation. A name is @@ -137,7 +140,7 @@ public sealed class ScenarioStage } // The task a piloted entity starts on: it closes on the target and holds there (the flight's follow task, standoff - // nothing), in place of the patrol circuit the zone would draw for it. A check that rests on where a pilot goes states + // nothing), in place of the patrol circuit the zone would draw for it. The pilot starts knowing it (RunStart seeds a sensor track). A check that rests on where a pilot goes states // it here, so the pilot's course is the scenario's and not a draw. An entity that is not placed and piloted, or a // target that is not placed, is a failure. public void Approach(Entity entity, Entity target) @@ -145,7 +148,11 @@ public sealed class ScenarioStage var placement = _placed.FirstOrDefault(p => ReferenceEquals(p.Entity, entity)); if (placement == null || !placement.Piloted) { _failures.Add($"approach: {entity?.Name ?? "no entity"} is not a placed, piloted ship"); return; } if (target == null) { _failures.Add("approach: no target to close on"); return; } - placement.Task = new FollowTask { Anchor = target, Standoff = 0f }; + // The one production handle minted from truth: the stage states the pilot's knowledge of its anchor (ruling + // stage-states-pilot-knowledge), and RunStart seeds the matching sensor track. The view still answers about the + // anchor only while the pilot's sensors hold it. + placement.Task = new FollowTask { Anchor = PilotView.Handle(target), Standoff = 0f }; + placement.Knows = target; } // Items in the entity's first cargo bay, one per name. A name is an equippable or consumable design (open: the stage diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index f2276f4e4..fe3136464 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -69,10 +69,9 @@ 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(); + // One flight per side, created on its first piloted join. A side is a faction, or one unaffiliated ship + // (brain-one-per-side-r2). A flight owns what its members target and do (Agents/Flight.cs). + 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 @@ -177,6 +176,8 @@ public class Zone if (agent == null) return; Agents.Remove(agent); foreach (var flight in _flights.Values) flight.Leave(agent); + // A side of one is gone with its ship. + if (remove.Value.Faction == null) _flights.Remove(remove.Value); }); Targets.Add(new EntityTargets()); @@ -310,9 +311,11 @@ public class Zone return true; } + private static object SideOf(Ship ship) => (object) ship.Faction ?? ship; + private Agent CreateAgent(Ship ship, AgentTask stated) { - var key = ship.Faction ?? Unaffiliated; + var key = SideOf(ship); if (!_flights.TryGetValue(key, out var flight)) _flights[key] = flight = new Flight(ship.Faction?.Doctrine, _itemManager.GameplaySettings); var id = NextAgentId(); diff --git a/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs b/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs new file mode 100644 index 000000000..5fdf7bffd --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs @@ -0,0 +1,67 @@ +using System.Linq; +using CultMath; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; + +// A staged Approach states what its pilot already knows (ruling stage-states-pilot-knowledge): the anchor is a sensor +// track from the first step, and the pilot follows it only as long as the sensors keep it. +public sealed partial class RunStartTests +{ + private (Zone arena, Ship east, Ship west) StagedApproach(float westX = -450f) + { + Ship east = null, west = null; + var (_, arena, _, failures) = Launch(new Scripted(false, stage => + { + stage.Player(stage.Bare("Djinni"), float2(-50000, -50000)); + east = stage.Place(EvLonginus(stage), float2(450, 0), facing: float2(-1, 0), piloted: true) as Ship; + west = stage.Place(EvLonginus(stage), float2(westX, 0), facing: float2(1, 0), piloted: true) as Ship; + stage.Approach(east, west); + })); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + return (arena, east, west); + } + + [Fact] + public void AStagedApproachFollowerClosesOnItsAnchorFromTheFirstStep() + { + var (arena, east, west) = StagedApproach(); + var view = arena.Agents.Single(a => a.Ship == east).View; + var startGap = east.Position.x - west.Position.x; + + arena.Update(1f / 60f); + arena.Update(1f / 60f); + + Assert.NotNull(view.TrackOf(east, PilotView.Handle(west))); + Assert.True(east.Velocity.x < -.5f, $"the follower thrusts toward its anchor from the first steps (vx {east.Velocity.x})"); + for (var i = 0; i < 120; i++) arena.Update(1f / 60f); + Assert.True(east.Position.x - west.Position.x < startGap - 20f, "the follower closed on its anchor"); + } + + // The anchor is staged 60 km off, far outside sensor reach: the staged knowledge decays through the sensors like any + // track (the Sensor is the only writer after staging), and the follower stops once they have dropped it. Nothing + // here writes the pilot's knowledge after the stage. + [Fact] + public void AStagedApproachFollowerStopsFollowingOnceTheSensorsLoseTheTrack() + { + var (arena, east, west) = StagedApproach(westX: -60000f); + var view = arena.Agents.Single(a => a.Ship == east).View; + var sentinel = new float3(0, 0, 12345); + + Assert.NotNull(view.TrackOf(east, PilotView.Handle(west))); + for (var i = 0; i < 3; i++) arena.Update(1f / 60f); + Assert.NotNull(view.TrackOf(east, PilotView.Handle(west))); + east.Command.Aim = sentinel; + arena.Update(1f / 60f); + Assert.NotEqual(sentinel, east.Command.Aim.Value); + + var steps = 0; + while (view.TrackOf(east, PilotView.Handle(west)) != null && steps++ < 1200) arena.Update(.1f); + Assert.Null(view.TrackOf(east, PilotView.Handle(west))); + + east.Command.Aim = sentinel; + arena.Update(.1f); + Assert.Equal(sentinel, east.Command.Aim.Value); + } +} diff --git a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs index ea1ee917e..f0a570b1f 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs @@ -586,7 +586,8 @@ public sealed class ConsumableAiTests : IDisposable s.Zone.Update(.05f); s.Zone.Update(.05f); Assert.True(s.Agent.Target.Value.IsNone); - agent.Task = new FollowTask { Anchor = s.Prey, Standoff = 100 }; + if (!s.Agent.VisibleEntities.Contains(s.Prey)) s.Agent.VisibleEntities.Add(s.Prey); + agent.Task = new FollowTask { Anchor = PilotView.Handle(s.Prey), Standoff = 100 }; for (var i = 0; i < 6 && !(s.State is FollowState); i++) { agent.Update(.05f); diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs index 1a1a64b1e..65be67d68 100644 --- a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -24,7 +24,13 @@ public sealed partial class RunStartTests // The two-AI fight's digest, captured on Yggdrasil. Replays are trusted only on the host class that recorded them // (ruling replays-per-host-class), and arenas run on Linux. private const string TwoAiPinnedHostClass = "linux-x64"; - private const string TwoAiPinnedDigest = "8ba2116ed0ecd574703846f493c408936d536e3b7c9e4652556103ec1b75098c"; + // Repinned twice, and the second repin has two causes (Soul measured the steps: group 1 + seed = f19b9992, + the Follow + // gate = ce81a426; group 2 without the seed = bf586806). + // First for the side split: the two fighters are unaffiliated, were one side and each followed itself (1b2ffe51). + // Then for staged knowledge (ruling repin-for-staged-knowledge), which moved it twice: the fighters are staged knowing + // each other, so they engage at step 0 (f19b9992); and Follow now flies only on an anchor the pilot itself tracks, so + // once the sensors drop the staged anchor the follower stops following it (ce81a426). + private const string TwoAiPinnedDigest = "ce81a426c40495e4b4b7b7f83ad23767cc781dce5523d713b4455e0aa5b7fe33"; private const int TwoAiSteps = 3600; [Fact] diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index bd576b16b..3a06a63f7 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -400,6 +400,96 @@ public sealed class FireControlPerWeaponTests : IDisposable AssertAimsAtTarget(r); } + // A brain's fire-control readouts are FireControl's own answers on the resolved subject (A-one-sim), and its reveal + // list is the book's weapons in fitted order with Aimable = IsRevealed. Kills: a readout with its own arithmetic, a + // dropped aim, an intercept left at the planar height, a reveal list in book order. + [Fact] + public void ViewReadoutsAreFireControls() + { + var r = Build(velocity: 500f, targetLayerHeight: 40f); + var targetGun = r.Target.Equipment.Single(i => i.Behaviors.OfType().Any()); + r.Shooter.RecordReveal(r.Target, targetGun, SignatureChannel.LaunchFlash, targetGun.Data); + var view = new PilotView(r.Shooter); + var contact = PilotView.Handle(r.Target); + var weapon = r.Gun.Weapon; + var minHit = r.Shooter.ItemManager.GameplaySettings.AgentMinHitProbability; + + Assert.True(FireControl.Designated(weapon, r.Shooter, r.Target, out _)); + Assert.True(view.Designated(r.Shooter, weapon, contact)); + Assert.Equal(FireControl.Refuses(weapon, r.Shooter, r.Target), view.Refuses(r.Shooter, weapon, contact)); + Assert.Equal(FireControl.AgentFires(weapon, r.Shooter, r.Target, minHit, AimTarget.None), + view.Fires(r.Shooter, weapon, contact, minHit, null)); + + var reveal = Assert.Single(view.WeaponReveals(r.Shooter, contact)); + Assert.Same(targetGun.Data, reveal.Claimed); + Assert.Equal(FireControl.IsRevealed(r.Shooter, targetGun), reveal.Aimable); + Assert.True(reveal.Aimable); + Assert.Equal(FireControl.AgentFires(weapon, r.Shooter, r.Target, minHit, AimTarget.OfItem(targetGun)), + view.Fires(r.Shooter, weapon, contact, minHit, reveal.Id)); + Assert.Equal(FireControl.AgentFires(weapon, r.Shooter, r.Target, 0f, AimTarget.OfItem(targetGun)), + view.Fires(r.Shooter, weapon, contact, 0f, reveal.Id)); + Assert.Equal(FireControl.AgentFires(weapon, r.Shooter, r.Target, 2f, AimTarget.OfItem(targetGun)), + view.Fires(r.Shooter, weapon, contact, 2f, reveal.Id)); + + view.SelectItem(r.Shooter, reveal.Id); + view.SelectItem(r.Shooter, null); + PilotApplier.Apply(r.Shooter); + + var expected = FireControl.PredictedIntercept(weapon, r.Shooter, r.Target); + r.Target.Position = new float3(r.Target.Position.x, 0, r.Target.Position.z); + expected = FireControl.PredictedIntercept(weapon, r.Shooter, r.Target); + var planarY = expected.y; + expected.y = r.Target.RideHeight(expected.xz); + Assert.NotEqual(planarY, expected.y); + Assert.Equal(expected, view.Intercept(r.Shooter, weapon, contact).Value); + } + + // A view answers fire control only for a member that tracks the contact: the shooter outside the view, or having lost + // the track, gets no intercept, no reveals, no designated weapon, a refusal, and no shot; a write for a non-member + // changes nothing. The member that tracks is the positive control, answering as FireControl does. Kills: any of + // Intercept, WeaponReveals, Designated, Refuses, Fires or SelectItem answering without the member's own track or + // membership. + public enum Standing { TrackingMember, NotAMember, LostTheTrack } + + [Theory] + [InlineData(Standing.TrackingMember)] + [InlineData(Standing.NotAMember)] + [InlineData(Standing.LostTheTrack)] + public void AViewAnswersFireControlOnlyForAMemberThatTracksTheContact(Standing standing) + { + var r = Build(velocity: 500f); + var targetGun = r.Target.Equipment.Single(i => i.Behaviors.OfType().Any()); + r.Shooter.RecordReveal(r.Target, targetGun, SignatureChannel.LaunchFlash, targetGun.Data); + var view = standing == Standing.NotAMember ? new PilotView() : new PilotView(r.Shooter); + var contact = PilotView.Handle(r.Target); + var weapon = r.Gun.Weapon; + var reveal = new PilotView(r.Shooter).WeaponReveals(r.Shooter, contact).Single(); + if (standing == Standing.LostTheTrack) r.Shooter.VisibleEntities.Remove(r.Target); + var minHit = 0f; + + var intercept = view.Intercept(r.Shooter, weapon, contact); + var reveals = view.WeaponReveals(r.Shooter, contact); + view.SelectItem(r.Shooter, reveal.Id); + if (standing == Standing.TrackingMember) + { + Assert.NotNull(intercept); + Assert.Single(reveals); + Assert.True(view.Designated(r.Shooter, weapon, contact)); + Assert.False(view.Refuses(r.Shooter, weapon, contact)); + Assert.True(view.Fires(r.Shooter, weapon, contact, minHit, null)); + Assert.Single(r.Shooter.Command.Selections); + } + else + { + Assert.Null(intercept); + Assert.Empty(reveals); + Assert.False(view.Designated(r.Shooter, weapon, contact)); + Assert.True(view.Refuses(r.Shooter, weapon, contact)); + Assert.False(view.Fires(r.Shooter, weapon, contact, minHit, null)); + if (standing == Standing.NotAMember) Assert.Empty(r.Shooter.Command.Selections); + } + } + private static void AssertAimsAtTarget(Rig r) { var toTarget = r.Target.Position - r.Shooter.Position; diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs index 7bab33174..1cb2f9c48 100644 --- a/tests/Aetheria.Shared.Tests/FlightTests.cs +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -48,9 +48,17 @@ public sealed class FlightTests : IDisposable return track; } + // A follower holds the anchor as a handle and flies on it only while its side tracks it. + private static FollowTask Follows(Ship follower, Entity anchor, float standoff) + { + if (!follower.VisibleEntities.Contains(anchor)) follower.VisibleEntities.Add(anchor); + return new FollowTask { Anchor = PilotView.Handle(anchor), Standoff = standoff }; + } + private static void See(Ship member, Entity track) { member.EntityInfoGathered[track] = 1; + if (!member.VisibleEntities.Contains(track)) member.VisibleEntities.Add(track); member.VisibleEnemies.Add(track); } @@ -254,7 +262,7 @@ public sealed class FlightTests : IDisposable { anchor.Position = ship.Position + anchorAt; var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) - { Task = new FollowTask { Anchor = anchor, Standoff = 100 } }; + { Task = Follows(ship, anchor, 100) }; minion.Update(.1f); PilotApplier.Apply(minion.Ship); minion.Update(.1f); @@ -279,7 +287,7 @@ public sealed class FlightTests : IDisposable for (var i = 0; i < 4; i++) minion.Update(.1f); Assert.IsType(CurrentState(minion)); - minion.Task = new FollowTask { Anchor = anchor, Standoff = 50 }; + minion.Task = Follows(ship, anchor, 50); minion.Update(.1f); PilotApplier.Apply(minion.Ship); @@ -359,14 +367,18 @@ public sealed class FlightTests : IDisposable } [Fact] - public void EachFactionFliesAFlightOfItsOwnAndUnaffiliatedShipsShareOne() + public void EachFactionFliesAFlightOfItsOwnAndEachUnaffiliatedShipFliesAlone() { Pilot(new Faction { Name = "One" }); Pilot(new Faction { Name = "Two" }); Pilot(null); - Pilot(null); + var alone = Pilot(null); - Assert.Equal(new[] { 1, 1, 2 }, _zone.Flights.Select(f => f.Count).OrderBy(c => c)); + Assert.Equal(new[] { 1, 1, 1, 1 }, _zone.Flights.Select(f => f.Count).OrderBy(c => c)); + + _zone.Remove(alone); + + Assert.Equal(3, _zone.Flights.Count()); } [Fact] @@ -540,12 +552,12 @@ public sealed class FlightTests : IDisposable See(member, far); Tick(.1f); - Assert.Same(near, ((FollowTask) _zone.Agents.Single().Task).Anchor); + Assert.Equal(PilotView.Handle(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); + Assert.Equal(PilotView.Handle(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 @@ -559,12 +571,12 @@ public sealed class FlightTests : IDisposable See(member, near); See(member, far); Tick(.1f); - Assert.Same(near, ((FollowTask) _zone.Agents.Single().Task).Anchor); + Assert.Equal(PilotView.Handle(near), ((FollowTask) _zone.Agents.Single().Task).Anchor); member.VisibleEnemies.Remove(near); Tick(.1f); - Assert.Same(far, ((FollowTask) _zone.Agents.Single().Task).Anchor); + Assert.Equal(PilotView.Handle(far), ((FollowTask) _zone.Agents.Single().Task).Anchor); } [Fact] @@ -582,7 +594,7 @@ public sealed class FlightTests : IDisposable member.VisibleEnemies.Remove(track); Tick(.1f); Assert.Same(patrol, agent.Task); - Assert.Equal(Flight.Phase.None, _zone.Flights.Single().PhaseOf(track)); + Assert.Equal(Flight.Phase.None, _zone.Flights.Single().PhaseOf(PilotView.Handle(track))); member.Messages.Clear(); See(member, track); @@ -613,7 +625,7 @@ public sealed class FlightTests : IDisposable 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 } }; + { Task = Follows(ship, anchor, 100) }; minion.Update(.1f); PilotApplier.Apply(minion.Ship); minion.Update(.1f); @@ -635,7 +647,7 @@ public sealed class FlightTests : IDisposable var ship = Pilot(null); var anchor = Track(); var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) - { Task = new FollowTask { Anchor = anchor, Standoff = 50 } }; + { Task = Follows(ship, anchor, 50) }; minion.Update(.1f); PilotApplier.Apply(minion.Ship); minion.Update(.1f); @@ -647,7 +659,7 @@ public sealed class FlightTests : IDisposable PilotApplier.Apply(minion.Ship); Assert.IsType(CurrentState(minion)); - minion.Task = new FollowTask { Anchor = anchor, Standoff = 50 }; + minion.Task = Follows(ship, anchor, 50); minion.Update(.1f); PilotApplier.Apply(minion.Ship); minion.Update(.1f); @@ -696,7 +708,7 @@ public sealed class FlightTests : IDisposable 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); + Assert.Equal(PilotView.Handle(track), task.Anchor); } // The flight's engaged track outranks a hailed one a blind member does see, though that one is nearer. Both sit @@ -714,14 +726,14 @@ public sealed class FlightTests : IDisposable 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 + Assert.Equal(PilotView.Handle(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); + Assert.Equal(PilotView.Handle(engaged), task.Anchor); } // Hail then engage is the production path of every graced doctrine: the member goes from FollowState into combat, @@ -883,7 +895,7 @@ public sealed class FlightTests : IDisposable 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 } }; + { Task = Follows(ship, anchor, 100) }; minion.Update(.1f); PilotApplier.Apply(minion.Ship); minion.Update(.1f); diff --git a/tests/Aetheria.Shared.Tests/LockOnScenarioTests.cs b/tests/Aetheria.Shared.Tests/LockOnScenarioTests.cs index 603a6bea0..e88abc220 100644 --- a/tests/Aetheria.Shared.Tests/LockOnScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/LockOnScenarioTests.cs @@ -23,10 +23,10 @@ public sealed partial class RunStartTests var toPlayer = normalize(player.Position.xz - hostile.Position.xz); Assert.True(dot(normalize(hostile.Direction), toPlayer) >= cos(radians(5f)), "the hostile faces the player"); - // The first real sensing tick: the hostile holds no track of the player, and the sim's own passive sensing, asked for - // the player's signature at the staged place, settles below the detection threshold: the staging is out of its sight. + // The hostile is staged knowing the player (ruling stage-states-pilot-knowledge): it starts with a track of it. The sim's + // own passive sensing, asked for the player's signature at the staged place, settles below the detection threshold: the + // staging is out of its sight, so the staged track is all that carries it in. arena.Update(.1f); - Assert.False(hostile.VisibleEnemies.Any(x => ReferenceEquals(x, player)), "the player is out of sight"); Assert.DoesNotContain(player.IncomingPaints, paint => paint.Mode == PaintMode.Track); Assert.True(player.Visibility > 0f, "the player shows a signature to be seen by"); var threshold = player.ItemManager.GameplaySettings.TargetDetectionInfoThreshold; diff --git a/tests/Aetheria.Shared.Tests/PerShooterAimTests.cs b/tests/Aetheria.Shared.Tests/PerShooterAimTests.cs new file mode 100644 index 000000000..e284abf56 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/PerShooterAimTests.cs @@ -0,0 +1,60 @@ +using System; +using System.Linq; +using System.Reflection; +using CultMath; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; + +// Fire control is per shooter (ruling per-shooter-fire-control): a pilot flies, aims and fires on what its own sensors +// track, not on a track only a sidemate holds. +public sealed partial class RunStartTests +{ + // East and west are one side, one view; only west tracks x. East is designated on x: it must not aim or pull on west's + // track (Combat reads TrackOf for the shooter, not SideTrack). The positive control has east track x as well, and it aims. + // Kills: Combat flying on SideTrack. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AMemberDesignatedOnAContactOnlyItsSidemateTracksNeitherAimsNorFires(bool eastTracks) + { + Ship east = null, west = null, x = null; + var (_, arena, _, failures) = Launch(new Scripted(false, stage => + { + stage.Player(stage.Bare("Djinni"), float2(-50000, -50000)); + east = stage.Place(EvLonginus(stage), float2(100, 0), facing: float2(-1, 0), piloted: true) as Ship; + west = stage.Place(EvLonginus(stage), float2(-100, 0), facing: float2(1, 0), piloted: true) as Ship; + x = stage.Place(EvLonginus(stage), float2(0, 300), facing: float2(0, -1)) as Ship; + })); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + var known = (east.ItemManager.GameplaySettings.TargetDetectionInfoThreshold + 1f) / 2f; + east.SetIff(x, true); + west.SetIff(x, true); + var side = new PilotView(east, west); + var agent = arena.Agents.Single(a => a.Ship == east); + agent.View = side; + west.EntityInfoGathered[x] = known; + if (eastTracks) east.EntityInfoGathered[x] = known; + Assert.NotNull(side.SideTrack(PilotView.Handle(x))); + Assert.Equal(eastTracks, side.TrackOf(east, PilotView.Handle(x)) != null); + + var sentinel = new float3(0, 0, -12345); + object state = null; + for (var i = 0; i < 4; i++) + { + east.Command.Designate(new TargetRef(x)); + east.Command.Aim = sentinel; + agent.Update(1f / 60f); + state = typeof(Agent).GetField("_currentState", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(agent); + } + Assert.IsType(state); + var aimed = !east.Command.Aim.Value.Equals(sentinel); + if (eastTracks) Assert.True(aimed, "a member that tracks the contact aims at it"); + else + { + Assert.False(aimed, $"a member flew its aim on a sidemate's track: {east.Command.Aim}"); + Assert.False(east.Weapons.Any(w => east.Command.Pulls(w)), "a member pulled a trigger on a sidemate's track"); + } + } +} diff --git a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs index 3bfeb9f42..a1d8870dc 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs @@ -454,6 +454,8 @@ public sealed partial class RunStartTests Assert.IsType(SeamState(agent)); Assert.Same(west, east.Target.Value.Entity); + // East is staged knowing the third ship, as an Approach follower knows its anchor: a sensor track by the sensors' own path. + east.EntityInfoGathered[third] = (east.ItemManager.GameplaySettings.TargetDetectionInfoThreshold + 1f) / 2f; east.SetTarget(TargetRef.None); east.Command.Designate(new TargetRef(third)); agent.Update(SeamDt); diff --git a/tests/Aetheria.Shared.Tests/PilotViewTests.cs b/tests/Aetheria.Shared.Tests/PilotViewTests.cs new file mode 100644 index 000000000..6d47fe3c4 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/PilotViewTests.cs @@ -0,0 +1,319 @@ +using System; +using System.Linq; +using CultMath; +using GameCult.Caching; +using UniRx; +using Xunit; + +// A side's brain reaches other ships only through its PilotView (Agents/PilotView.cs): handles it issued, tracks only +// of what a member tracks, picture only of what a member's sensors hold hostile. Driven on the IffAndCombatTests world, +// like FlightTests; perception is set directly, a grudge is a real IFF override. +public sealed class PilotViewTests : IDisposable +{ + private readonly IffAndCombatTests _world = new IffAndCombatTests(); + private readonly ItemManager _items; + private readonly Zone _zone; + + public PilotViewTests() => (_items, _zone) = _world.BuildWorld(); + + public void Dispose() => _world.Dispose(); + + 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 Subject(float x = 100, float z = 0) + { + var ship = _world.NewShip(_items, _zone, new Faction { Name = "Other" }); + ship.Position = new float3(x, 0, z); + return ship; + } + + private static void See(Ship member, Entity subject) + { + member.EntityInfoGathered[subject] = 1; + if (!member.VisibleEntities.Contains(subject)) member.VisibleEntities.Add(subject); + member.VisibleEnemies.Add(subject); + } + + private PilotView ViewOf(Ship ship) => _zone.Agents.Single(a => a.Ship == ship).View; + + private void Tick(float dt) + { + foreach (var flight in _zone.Flights.ToArray()) flight.Update(dt); + foreach (var entity in _zone.Entities.ToArray()) PilotApplier.Apply(entity); + } + + [Fact] + public void ASideNeverListsItsOwnMember() + { + var a = Pilot(null, -200); + var b = Pilot(null, 200); + a.SetIff(b, true); + b.SetIff(a, true); + See(a, b); + See(b, a); + + Assert.Equal(2, _zone.Flights.Count()); + Assert.Equal(new[] { PilotView.Handle(b) }, ViewOf(a).Picture()); + Assert.Equal(new[] { PilotView.Handle(a) }, ViewOf(b).Picture()); + for (var step = 0; step < 600; step++) + { + Tick(.1f); + foreach (var member in new[] { a, b }) + Assert.NotEqual(PilotView.Handle(member), (_zone.Agents.Single(x => x.Ship == member).Task as FollowTask)?.Anchor); + } + } + + [Fact] + public void AFactionsPictureLeavesOutItsOwnMembers() + { + var faction = new Faction { Name = "Squabblers" }; + var a = Pilot(faction, -200); + var b = Pilot(faction, 200); + var other = Subject(); + See(a, b); + See(b, a); + See(b, other); + + Assert.Equal(new[] { PilotView.Handle(other) }, ViewOf(a).Picture()); + } + + [Fact] + public void APictureIsTheMembersEnemiesInMemberOrderEachOnce() + { + var faction = new Faction { Name = "Plain" }; + var a = Pilot(faction, -10); + var b = Pilot(faction, 10); + var x = Subject(100); + var y = Subject(200); + var z = Subject(300); + See(a, x); + See(a, y); + See(b, y); + See(b, z); + + Assert.Equal(new[] { x, y, z }.Select(PilotView.Handle), ViewOf(a).Picture()); + } + + [Fact] + public void ATrackIsOnlyWhatTheMemberTracks() + { + var member = Pilot(null); + var subject = Subject(120, 30); + subject.Velocity = new float2(3, -4); + subject.Direction = new float2(0, -1); + var threshold = _items.GameplaySettings.TargetDetectionInfoThreshold; + var view = ViewOf(member); + var handle = PilotView.Handle(subject); + + member.EntityInfoGathered[subject] = threshold / 2; + Assert.Null(view.TrackOf(member, handle)); + Assert.Equal(threshold / 2, view.Info(member, handle)); + + member.EntityInfoGathered[subject] = threshold * 2; + var track = view.TrackOf(member, handle); + Assert.NotNull(track); + Assert.Equal(subject.Position, track.Value.Position); + Assert.Equal(subject.Velocity, track.Value.Velocity); + Assert.Equal(subject.Direction, track.Value.Direction); + Assert.Equal(threshold * 2, view.Info(member, handle)); + } + + [Fact] + public void ASideTrackComesFromATrackingMember() + { + var faction = new Faction { Name = "Plain" }; + var a = Pilot(faction, -10); + var b = Pilot(faction, 10); + var x = Subject(100); + var y = Subject(200); + See(b, x); + var view = ViewOf(a); + + Assert.Null(view.TrackOf(a, PilotView.Handle(x))); + Assert.Equal(x.Position, view.SideTrack(PilotView.Handle(x)).Value.Position); + Assert.Null(view.SideTrack(PilotView.Handle(y))); + } + + [Fact] + public void AViewAnswersIffOverridesAndTrespassPerMember() + { + var member = Pilot(null); + var subject = Subject(); + var view = ViewOf(member); + var handle = PilotView.Handle(subject); + + Assert.Null(view.IffOverride(member, handle)); + member.SetIff(subject, true); + Assert.True(view.IffOverride(member, handle)); + member.SetIff(subject, false); + Assert.False(view.IffOverride(member, handle)); + Assert.Equal(member.BelievesTrespassing(subject), view.BelievesTrespassing(member, handle)); + } + + [Fact] + public void SeeingIsTheMembersOwnEnemyList() + { + var faction = new Faction { Name = "Plain" }; + var a = Pilot(faction, -10); + var b = Pilot(faction, 10); + var x = Subject(); + See(b, x); + var view = ViewOf(a); + + Assert.True(view.Sees(b, PilotView.Handle(x))); + Assert.False(view.Sees(a, PilotView.Handle(x))); + } + + [Fact] + public void ATargetIsThePendingDesignationElseTheAppliedTarget() + { + var member = Pilot(null); + var x = Subject(100); + var y = Subject(200); + See(member, x); + See(member, y); + var view = ViewOf(member); + + Assert.Null(view.TargetOf(member)); + Assert.Null(view.AppliedTargetOf(member)); + + view.Designate(member, PilotView.Handle(x)); + Assert.Equal(PilotView.Handle(x), view.TargetOf(member)); + Assert.Null(view.AppliedTargetOf(member)); + + PilotApplier.Apply(member); + Assert.Equal(PilotView.Handle(x), view.AppliedTargetOf(member)); + Assert.Equal(PilotView.Handle(x), view.TargetOf(member)); + + view.Designate(member, PilotView.Handle(y)); + Assert.Equal(PilotView.Handle(y), view.TargetOf(member)); + Assert.Equal(PilotView.Handle(x), view.AppliedTargetOf(member)); + } + + [Fact] + public void DesignatingTheAppliedTargetAgainWritesNothing() + { + var member = Pilot(null); + var x = Subject(100); + See(member, x); + var view = ViewOf(member); + view.Designate(member, PilotView.Handle(x)); + PilotApplier.Apply(member); + + view.Designate(member, PilotView.Handle(x)); + Assert.Null(member.Command.Designated); + + view.Designate(member, null); + Assert.True(member.Command.Designated.Value.IsNone); + PilotApplier.Apply(member); + view.Designate(member, null); + Assert.Null(member.Command.Designated); + } + + [Fact] + public void AHandleIsTheContactAcrossItsAbsence() + { + var member = Pilot(null); + var x = Subject(100); + var y = Subject(200); + var view = ViewOf(member); + See(member, x); + var before = view.Picture().Single(); + + member.VisibleEnemies.Remove(x); + member.VisibleEntities.Remove(x); + Assert.Empty(view.Picture()); + See(member, x); + + Assert.Equal(before, view.Picture().Single()); + Assert.Equal(PilotView.Handle(x), before); + Assert.True(before == PilotView.Handle(x)); + Assert.Equal(PilotView.Handle(x).GetHashCode(), before.GetHashCode()); + Assert.NotEqual(PilotView.Handle(y), before); + Assert.True(PilotView.Handle(y) != before); + } + + [Fact] + public void ACombatingPilotFliesOnlyOnATrackedTarget() + { + var member = Pilot(null); + var subject = Subject(500, 300); + member.SetTarget(subject); + member.Position = new float3(500, 0, 0); + var agent = new Minion(member, FactionDoctrine.Default(_items.GameplaySettings).Combatant) + { Task = new PatrolOrbitsTask { Circuit = new CultRecordKey[1] } }; + + for (var i = 0; i < 4; i++) + { + agent.Update(.1f); + PilotApplier.Apply(member); + } + Assert.True((member.Command.Aim?.z ?? 0) < 100, "an untracked target is not flown on"); + + See(member, subject); + member.SetTarget(subject); + for (var i = 0; i < 4; i++) + { + agent.Update(.1f); + PilotApplier.Apply(member); + } + Assert.Equal(300f, member.Command.Aim.Value.z, 1); + } + + // The hostile part of Sees is the member's own: a member holding a sidemate's enemy as friendly (a hail it answered, an + // IFF override) does not see it as an enemy, though it tracks it. Kills: Sees reading VisibleEntities. + [Fact] + public void AMemberThatHoldsASidematesEnemyAsFriendlyDoesNotSeeItAsOne() + { + var faction = new Faction { Name = "Plain" }; + var a = Pilot(faction, -10); + var b = Pilot(faction, 10); + var x = Subject(); + See(b, x); + a.EntityInfoGathered[x] = 1; + a.VisibleEntities.Add(x); + a.SetIff(x, false); + var view = ViewOf(a); + var handle = PilotView.Handle(x); + + Assert.NotNull(view.TrackOf(a, handle)); + Assert.True(view.Sees(b, handle)); + Assert.False(view.Sees(a, handle)); + } + + // A view answers for its members only: every per-member read of a ship the view does not hold is empty, however much + // that ship knows, and a write for it changes nothing. One assertion per read, each killing its dropped membership check. + [Fact] + public void AViewAnswersNothingForAShipThatIsNotItsMember() + { + var a = Pilot(null, -200); + var outsider = Pilot(null, 200); + var x = Subject(); + See(outsider, x); + outsider.SetIff(x, true); + x.PresencePermitted = Observable.Return(false).ToReadOnlyReactiveProperty(); + outsider.Command.Designate(new TargetRef(x)); + var handle = PilotView.Handle(x); + var view = ViewOf(a); + Assert.NotNull(outsider.IffOverride(x)); + Assert.True(outsider.BelievesTrespassing(x)); + Assert.True(outsider.VisibleEnemies.Contains(x)); + + Assert.Null(view.TrackOf(outsider, handle)); + Assert.False(view.Sees(outsider, handle)); + Assert.Null(view.IffOverride(outsider, handle)); + Assert.Null(view.Info(outsider, handle)); + Assert.False(view.BelievesTrespassing(outsider, handle)); + Assert.Null(view.TargetOf(outsider)); + PilotApplier.Apply(outsider); + Assert.Null(view.AppliedTargetOf(outsider)); + + view.Designate(outsider, null); + Assert.Null(outsider.Command.Designated); + } +}