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:
soul 2026-10-22 18:50:35 +02:00
commit 0267a191c4
20 changed files with 905 additions and 101 deletions

View file

@ -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;

View file

@ -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;

View file

@ -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);
}
}

View 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));
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5b0f6c2e9d3a4f71a8c4e1d7b2a63f90
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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))

View file

@ -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);

View file

@ -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;
}

View file

@ -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() };
}

View file

@ -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

View file

@ -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();

View 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);
}
}

View file

@ -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);

View file

@ -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]

View file

@ -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;

View file

@ -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);

View file

@ -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;

View 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");
}
}
}

View file

@ -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);

View 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);
}
}