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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue