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