Merge eureka/aetheria-arena-view-seam: one PilotView per side; brains reach other ships only through their side's sensors; unaffiliated ships are sides of one
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
0267a191c4
20 changed files with 905 additions and 101 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Member> _members = new List<Member>();
|
||||
private readonly Dictionary<Entity, Track> _tracks = new Dictionary<Entity, Track>();
|
||||
private readonly Dictionary<ContactId, Track> _tracks = new Dictionary<ContactId, Track>();
|
||||
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<Entity> picture)
|
||||
private void Order(Member member, List<ContactId> 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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
206
Assets/Scripts/ServerShared/Agents/PilotView.cs
Normal file
206
Assets/Scripts/ServerShared/Agents/PilotView.cs
Normal file
|
|
@ -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<Ship> _members = new List<Ship>();
|
||||
private static Func<ContactId, Entity> _openContact;
|
||||
private static Func<RevealId, EquippedItem> _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<ContactId>
|
||||
{
|
||||
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<RevealId>
|
||||
{
|
||||
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<ContactId> Picture()
|
||||
{
|
||||
var picture = new List<ContactId>();
|
||||
var seen = new HashSet<Entity>();
|
||||
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));
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/ServerShared/Agents/PilotView.cs.meta
Normal file
11
Assets/Scripts/ServerShared/Agents/PilotView.cs.meta
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 5b0f6c2e9d3a4f71a8c4e1d7b2a63f90
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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<WeaponData>().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))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -69,10 +69,9 @@ public class Zone
|
|||
private ItemManager _itemManager;
|
||||
private double _time;
|
||||
public List<Agent> Agents = new List<Agent>();
|
||||
// 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<Faction, Flight> _flights = new Dictionary<Faction, Flight>();
|
||||
// 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<object, Flight> _flights = new Dictionary<object, Flight>();
|
||||
public IEnumerable<Flight> 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();
|
||||
|
|
|
|||
67
tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs
Normal file
67
tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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<Weapon>().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<Weapon>().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;
|
||||
|
|
|
|||
|
|
@ -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<MoveToOrbitState>(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<PatrolOrbitsState>(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<FollowTask>(_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<FollowTask>(_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<FollowTask>(_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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
60
tests/Aetheria.Shared.Tests/PerShooterAimTests.cs
Normal file
60
tests/Aetheria.Shared.Tests/PerShooterAimTests.cs
Normal file
|
|
@ -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<CombatState>(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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -454,6 +454,8 @@ public sealed partial class RunStartTests
|
|||
Assert.IsType<CombatState>(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);
|
||||
|
|
|
|||
319
tests/Aetheria.Shared.Tests/PilotViewTests.cs
Normal file
319
tests/Aetheria.Shared.Tests/PilotViewTests.cs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue