From 488b3fa434daa88439c161e88ecbe2657fbfaf5d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:26:07 +0200 Subject: [PATCH] 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 --- Assets/Scripts/ServerShared/Agents/Agent.cs | 2 +- .../Scripts/ServerShared/Agents/PilotView.cs | 83 +++++++++++++------ .../ServerShared/Agents/States/Combat.cs | 3 +- Assets/Scripts/ServerShared/ScenarioStage.cs | 3 + .../FireControlPerWeaponTests.cs | 2 +- 5 files changed, 65 insertions(+), 28 deletions(-) diff --git a/Assets/Scripts/ServerShared/Agents/Agent.cs b/Assets/Scripts/ServerShared/Agents/Agent.cs index 586438c97..03a76436a 100644 --- a/Assets/Scripts/ServerShared/Agents/Agent.cs +++ b/Assets/Scripts/ServerShared/Agents/Agent.cs @@ -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; } diff --git a/Assets/Scripts/ServerShared/Agents/PilotView.cs b/Assets/Scripts/ServerShared/Agents/PilotView.cs index 8c9a71e65..51cacf32f 100644 --- a/Assets/Scripts/ServerShared/Agents/PilotView.cs +++ b/Assets/Scripts/ServerShared/Agents/PilotView.cs @@ -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 _members = new List(); private static Func _openContact; private static Func _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)); + } } diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index a424437b3..f8c12baab 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -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 diff --git a/Assets/Scripts/ServerShared/ScenarioStage.cs b/Assets/Scripts/ServerShared/ScenarioStage.cs index a9e2db5d0..453ede8ec 100644 --- a/Assets/Scripts/ServerShared/ScenarioStage.cs +++ b/Assets/Scripts/ServerShared/ScenarioStage.cs @@ -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; } diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 5c0aa19a5..3a06a63f7 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -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