PilotView enforces its own rules: members only, fire-control readouts only on the member's own track; constructor and View setter internal

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-22 18:26:07 +02:00
commit 488b3fa434
5 changed files with 59 additions and 22 deletions

View file

@ -25,7 +25,7 @@ public class Agent
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; set; }
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; }

View file

@ -14,13 +14,22 @@ using CultMath;
// 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;
public PilotView(params Ship[] members) => _members.AddRange(members);
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);
@ -69,7 +78,9 @@ public sealed class PilotView
}
}
// For fixtures: the handle of a subject, as the view would issue it.
// 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)
@ -95,13 +106,13 @@ public sealed class PilotView
return picture;
}
public bool Sees(Ship member, ContactId contact) => member.VisibleEnemies.Contains(Subject(contact));
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 (subject == null || !member.VisibleEntities.Contains(subject)) return null;
if (!Tracks(member, subject)) return null;
return new Track(subject.Position, subject.Velocity, subject.Direction);
}
@ -117,45 +128,64 @@ public sealed class PilotView
return null;
}
public bool? IffOverride(Ship member, ContactId contact) => member.IffOverride(Subject(contact));
public bool? IffOverride(Ship member, ContactId contact) => Holds(member) ? member.IffOverride(Subject(contact)) : null;
public float? Info(Ship member, ContactId contact) =>
member.EntityInfoGathered.TryGetValue(Subject(contact), out var info) ? info : (float?) null;
Holds(member) && member.EntityInfoGathered.TryGetValue(Subject(contact), out var info) ? info : (float?) null;
public bool BelievesTrespassing(Ship member, ContactId contact) => member.BelievesTrespassing(Subject(contact));
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.
public List<(RevealId Id, EquippableItemData Claimed, bool Aimable)> WeaponReveals(Ship member, ContactId contact) =>
member.RevealedWeapons(Subject(contact))
.Select(r => (new RevealId(r.Item), r.Claimed, FireControl.IsRevealed(member, r.Item))).ToList();
public bool Designated(Ship member, Weapon weapon, ContactId contact) =>
FireControl.Designated(weapon, member, Subject(contact), out _);
public bool Refuses(Ship member, Weapon weapon, ContactId contact) =>
FireControl.Refuses(weapon, member, Subject(contact));
public bool Fires(Ship member, Weapon weapon, ContactId contact, float minHitProbability, RevealId? aim) =>
FireControl.AgentFires(weapon, member, Subject(contact), 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.
public float3 Intercept(Ship member, Weapon weapon, ContactId contact)
// 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;
@ -164,10 +194,13 @@ public sealed class PilotView
// 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) =>
member.Command.SelectItem(reveal == null ? null : Item(reveal.Value));
public void SelectItem(Ship member, RevealId? reveal)
{
if (Holds(member)) member.Command.SelectItem(reveal == null ? null : Item(reveal.Value));
}
}

View file

@ -134,8 +134,9 @@ public class CombatState : BaseState
// 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.
// The target is tracked here (the guard above), so the intercept is never null.
var predictedPosition = view.Intercept(_agent.Ship, testWeapon, target.Value);
toTarget = normalize(predictedPosition - _agent.Ship.Position);
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

View file

@ -148,6 +148,9 @@ 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; }
// 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;
}

View file

@ -441,7 +441,7 @@ public sealed class FireControlPerWeaponTests : IDisposable
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));
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