From 095efacd79e7e28993a41f0f9ba7e92863367e58 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 15 Oct 2026 00:17:14 +0200 Subject: [PATCH] Layout-aim: an identified hull shows where its mounts are; a located mount can be aimed at TrySelectTargetItem and ResolvedTargetItem gate on FireControl.IsLocated (an item seen to act, or one on a mount of the believed hull's layout) instead of IsRevealed; Combat keeps IsRevealed. Entity.LayoutOf derives the layout from the Contact profile; SchematicRows appends a mount row per unclaimed hardpoint. A mount aim centres on the mount's cells and is frozen per shot (PendingShot.AimAtMount). The cycle runs over located items, the schematic renders mount rows and marks through SchematicRow.Marks. Not yet built or tested. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 14 +++---- Assets/Scripts/ServerShared/Entity.cs | 40 +++++++++++++------- Assets/Scripts/ServerShared/FireControl.cs | 40 ++++++++++++++++---- Assets/Scripts/ServerShared/Sensing.cs | 16 +++++++- Assets/Scripts/UI/HUD/SchematicDisplay.cs | 6 ++- 5 files changed, 83 insertions(+), 33 deletions(-) diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 0877a4a03..6d1b23dfb 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -441,21 +441,21 @@ public class ActionGameManager : MonoBehaviour Input.Player.TargetNext.performed += context => CurrentEntity.TargetNext(); Input.Player.TargetPrevious.performed += context => CurrentEntity.TargetPrevious(); - // Cut 2 (docs/fire-control-cut.md): cycles the aim point among the current target's revealed - // subsystems -- decides nothing itself, only calls the one writer (TrySelectTargetItem), same - // predicate (FireControl.IsRevealed) the AI path uses. Entity-only: a chunk has no subsystems. + // Cut 2 (docs/fire-control-cut.md): cycles the aim point among the current target's located + // subsystems -- decides nothing itself, only calls the one writer (TrySelectTargetItem), gated on the + // same predicate (FireControl.IsLocated) the writer reads. Entity-only: a chunk has no subsystems. Input.Player.CycleTargetItem.performed += context => { var target = CurrentEntity.Target.Value.Entity; if (target == null) return; - var revealed = target.Equipment.Where(x => FireControl.IsRevealed(CurrentEntity, x)).ToArray(); - if (revealed.Length == 0) + var located = target.Equipment.Where(x => FireControl.IsLocated(CurrentEntity, x)).ToArray(); + if (located.Length == 0) { CurrentEntity.TrySelectTargetItem(null); return; } - var currentItemIndex = Array.IndexOf(revealed, CurrentEntity.ResolvedTargetItem); - CurrentEntity.TrySelectTargetItem(revealed[(currentItemIndex + 1) % revealed.Length]); + var currentItemIndex = Array.IndexOf(located, CurrentEntity.ResolvedTargetItem); + CurrentEntity.TrySelectTargetItem(located[(currentItemIndex + 1) % located.Length]); }; #endregion diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 9c045eb8e..226204133 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -71,9 +71,9 @@ public abstract class Entity // Cut 2 (docs/fire-control-cut.md): the stored aim-point slot. Written only by TrySelectTargetItem // below, and nulled whenever Target changes (subscribed in Activate). Do not read this field directly - // outside Entity -- read ResolvedTargetItem, which re-validates on every call, so decay (or the item - // leaving the target) drops the aim point the moment it is no longer earned, with no loop that has to - // notice and clear it. + // outside Entity -- read ResolvedTargetItem, which re-validates on every call (FireControl.IsLocated), so decay + // (or the item leaving the target, or being destroyed) drops the aim point the moment it is no longer earned, + // with no loop that has to notice and clear it. public ReactiveProperty TargetItem = new ReactiveProperty((EquippedItem)null); // Where the pilot points: reticle, lock cone, tractor, guided rounds, fire direction. Steers nothing; the @@ -408,8 +408,9 @@ public abstract class Entity } // Cut 2 (docs/fire-control-cut.md): the only writer of TargetItem. Accepts null (clearing the aim - // point) or an item belonging to the current Target that FireControl.IsRevealed resolves true for this - // entity as observer. Returns whether the write took effect; a rejected non-null selection leaves + // point) or an item belonging to the current Target that FireControl.IsLocated resolves true for this + // entity as observer (an item it saw act, or one on a mount of the hull it believes the target to be). + // Returns whether the write took effect; a rejected non-null selection leaves // TargetItem unchanged. public bool TrySelectTargetItem(EquippedItem item) { @@ -420,7 +421,7 @@ public abstract class Entity } // Mining Cut 3: entity-only. A chunk has no items, so an item never belongs to a chunk target. - if (Target.Value.Entity == null || item.Entity != Target.Value.Entity || !FireControl.IsRevealed(this, item)) + if (Target.Value.Entity == null || item.Entity != Target.Value.Entity || !FireControl.IsLocated(this, item)) return false; TargetItem.Value = item; @@ -428,10 +429,10 @@ public abstract class Entity } // The read path for TargetItem (see the field's own comment): null once the aimed item is no longer - // revealed to this entity, or no longer belongs to the current Target, even though nothing wrote + // located by this entity, or no longer belongs to the current Target, even though nothing wrote // TargetItem.Value at the moment that became true. Re-checked on every call; never cached. public EquippedItem ResolvedTargetItem => - TargetItem.Value != null && Target.Value.Entity == TargetItem.Value.Entity && FireControl.IsRevealed(this, TargetItem.Value) + TargetItem.Value != null && Target.Value.Entity == TargetItem.Value.Entity && FireControl.IsLocated(this, TargetItem.Value) ? TargetItem.Value : null; @@ -1058,12 +1059,23 @@ public abstract class Entity // The rows of `subject`'s schematic as this ship reads it: its own weapons when `subject` is this ship (each row its // own data and live range, nothing to doubt about your own ship), otherwise the revealed weapons (each row the claimed - // class and that design's rated range) then the revealed gear (each row the claimed class, no range). The one owner of - // which rows, which class and which range an enemy schematic shows; the Unity SchematicDisplay renders it. - public IEnumerable SchematicRows(Entity subject) => - subject == this - ? subject.Equipment.Where(i => i.Behaviors.Any(b => b.Data is WeaponData)).Select(i => new SchematicRow(i, i.Data, true)) - : RevealedWeapons(subject).Concat(RevealedGear(subject)).Select(r => new SchematicRow(r.Item, r.Claimed, false)); + // class and that design's rated range) then the revealed gear (each row the claimed class, no range), then one mount + // row for each mount of the hull this ship believes `subject` to be (LayoutOf) that no claimed-item row sits on + // (ruling quiet-gear-identifiable-hull-layout-known). The one owner of which rows, which class and which range an + // enemy schematic shows; the Unity SchematicDisplay renders it. + public IEnumerable SchematicRows(Entity subject) + { + if (subject == this) + return subject.Equipment.Where(i => i.Behaviors.Any(b => b.Data is WeaponData)).Select(i => new SchematicRow(i, i.Data, true)); + var claimed = RevealedWeapons(subject).Concat(RevealedGear(subject)).Select(r => new SchematicRow(r.Item, r.Claimed, false)).ToList(); + return claimed.Concat(LayoutOf(subject) + .Where(h => !claimed.Any(r => r.Item.Hardpoint == h)) + .Select(h => new SchematicRow(h))); + } + + // Where `subject`'s mounts are as this ship believes them: the hull it is identified as (Contact.Profile, the claimed + // hull), never the subject's own HullData; empty below identification. Derived on every read, nothing stored. + public IReadOnlyList LayoutOf(Entity subject) => ContactOf(subject).Profile?.Hardpoints ?? (IReadOnlyList) Array.Empty(); // Whether this observer's book for `subject` holds `item`: the book's membership, no live gear read. internal bool HasReveal(Entity subject, EquippedItem item) => diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 9ce4e55ce..041c6b2ff 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -156,10 +156,25 @@ public static class FireControl // quiet-gear-identifiable-hull-layout-known, reveal-ends-on-perception): `observer` can aim at `item` when its own // book holds a reveal of it (Entity.HasReveal: it saw the item act) and the item is still fitted to its ship and // not destroyed. The book says what was seen; the live item says whether it can still be aimed at. Derived on - // every read, never cached; watching a ship reveals no gear. + // every read, never cached; watching a ship reveals no gear. Any aiming choice that reads an item's class or + // stats (Combat's target-item choice, its dps) asks this; the selection gate is IsLocated. public static bool IsRevealed(Entity observer, EquippedItem item) => !item.EquippableItem.IsDestroyed && item.Entity.Equipment.Contains(item) && observer.HasReveal(item.Entity, item); + // The selection gate (ruling quiet-gear-identifiable-hull-layout-known): `observer` can aim at `item` when it has seen + // the item act (IsRevealed's question) or the item sits on a mount of the hull the observer believes its ship to be + // (Entity.LayoutOf, by reference: the believed hull's HardpointData are the true item's only while the belief is the + // truth, so under a hull lie no gear is located and the ship stays targetable, ruling ew-aim-under-hull-lie-ruled). + // Locating reads no class or stats. A Tool item sits on no mount, so only an act locates it. + public static bool IsLocated(Entity observer, EquippedItem item) => + !item.EquippableItem.IsDestroyed && item.Entity.Equipment.Contains(item) && + (observer.HasReveal(item.Entity, item) || item.Hardpoint != null && observer.LayoutOf(item.Entity).Contains(item.Hardpoint)); + + // Whether the aim at `aimed` is at its mount (located, not revealed): the aim point is then the mount's cells, not + // the cells of an item the observer has not learned the size of. + public static bool AimsAtMount(Entity observer, EquippedItem aimed) => + aimed != null && aimed.Hardpoint != null && !IsRevealed(observer, aimed); + // Cut 2, Q4: the stats an entity actually fires with. A working targeting system (equipped, online -- // not destroyed, not shut down) supplies its own resolved stats; anything else falls back to the // authored unaided figures, which make aiming without one a last resort rather than an alternative. @@ -337,7 +352,7 @@ public static class FireControl Silhouette sil; try { - sil = Silhouette(target, targetHull, source.ResolvedTargetItem, bearing, precision, buffer); + sil = Silhouette(target, targetHull, source.ResolvedTargetItem, bearing, precision, buffer, AimsAtMount(source, source.ResolvedTargetItem)); var settings = source.ItemManager.GameplaySettings; pSpread = PSpread(weapon.Spread, sil.Span, range, settings.SchematicCellSize); } @@ -723,6 +738,7 @@ public static class FireControl Target = engaged, Weapon = item, Aimed = source.ResolvedTargetItem, + AimAtMount = AimsAtMount(source, source.ResolvedTargetItem), Damage = damageOverride ?? weapon.Damage, FireMinRange = weapon.MinRange, FireEffectiveRange = weapon.EffectiveRange, @@ -895,7 +911,7 @@ public static class FireControl var bearing = Bearing(shot.Target, shot.TravelDirection); var hullData = shot.Target.ItemManager.GetData(shot.Target.Hull) as HullData; - sil = Silhouette(shot.Target, hullData, shot.Aimed, bearing, shot.Precision); + sil = Silhouette(shot.Target, hullData, shot.Aimed, bearing, shot.Precision, null, shot.AimAtMount); var settings = shot.Target.ItemManager.GameplaySettings; return p * PSpread(shot.Spread, sil.Span, shot.FireRange, settings.SchematicCellSize) * sil.POnHull; } @@ -1763,11 +1779,12 @@ public static class FireControl private static float Sigma(float precision) => max(1f / max(precision, 1e-3f), SigmaFloor); // The aim point a silhouette is centred on (R10/Q6: the same frozen Aimed both HitProbability and Fire - // read) -- the aimed item's own cell centroid when it currently occupies cells on the target, else the + // read) -- the aimed item's own cell centroid when it currently occupies cells on the target (its mount's cells + // when the aim was frozen at the mount, atMount: the shooter has not learned what is fitted there), else the // hull's own centre of mass. One path; the uniform-random branch Cut 6d replaced stays deleted. - private static float2 ResolveAimPoint(Entity target, HullData hullData, EquippedItem aimed) + private static float2 ResolveAimPoint(Entity target, HullData hullData, EquippedItem aimed, bool atMount) { - var aimedCells = aimed != null ? CellsOf(target, aimed) : null; + var aimedCells = aimed == null ? null : atMount && aimed.Hardpoint != null ? MountCells(aimed.Hardpoint) : CellsOf(target, aimed); if (aimedCells != null && aimedCells.Length > 0) return aimedCells.Aggregate(float2.zero, (total, c) => total + (float2) c) / aimedCells.Length; return hullData.Shape.CenterOfMass; @@ -1787,7 +1804,7 @@ public static class FireControl // call that follows, so it still passes null and gets an owned allocation. A caller-supplied buffer may be // larger than coords.Length (ArrayPool rents "at least"), so every loop below is bounded by coords.Length, // never buffer.Length. - public static Silhouette Silhouette(Entity target, HullData hull, EquippedItem aimed, float2 bearing, float precision, Interval[] buffer = null) + public static Silhouette Silhouette(Entity target, HullData hull, EquippedItem aimed, float2 bearing, float precision, Interval[] buffer = null, bool atMount = false) { var ell = Lateral(bearing); var coords = hull.Shape.Coordinates; @@ -1809,7 +1826,7 @@ public static class FireControl } var sigma = Sigma(precision); - var aimPoint = ResolveAimPoint(target, hull, aimed); + var aimPoint = ResolveAimPoint(target, hull, aimed, atMount); var a = dot(aimPoint, ell); var pOnHull = 0f; @@ -1964,6 +1981,10 @@ public static class FireControl exit = min(exit, max(t1, t2)); } + // The hull-schematic cells a hardpoint covers, laid as Entity builds its Hardpoints[,] grid. + private static int2[] MountCells(HardpointData hardpoint) => + hardpoint.Shape.Coordinates.Select(c => hardpoint.Position + c).ToArray(); + // The cells of the target's hull schematic actually occupied by `item` -- GearOccupancy is the one source // of truth for where an equipped item's footprint lands, the same table Detonate and ApplyPooled read. private static int2[] CellsOf(Entity target, EquippedItem item) @@ -1988,6 +2009,9 @@ public struct PendingShot public Entity Target; public EquippedItem Weapon; public EquippedItem Aimed; + // Whether the aim was at Aimed's mount rather than the item (FireControl.AimsAtMount at Fire), frozen with it: a + // reveal between Fire and Commit does not move where this shot was aimed. + public bool AimAtMount; // The frozen payload (R10, Q6): the gun's own stats at the instant the trigger was pulled. Nothing // downstream re-evaluates any of these. diff --git a/Assets/Scripts/ServerShared/Sensing.cs b/Assets/Scripts/ServerShared/Sensing.cs index 526a8db23..fe6d1e78c 100644 --- a/Assets/Scripts/ServerShared/Sensing.cs +++ b/Assets/Scripts/ServerShared/Sensing.cs @@ -127,10 +127,13 @@ public readonly struct Reveal // on the viewer's own ship, the live gear. `Claimed` is the class the row shows. The range label is the live range on // the viewer's own ship and the claimed design's rated range on any other; a row whose claimed class is not a weapon has // no range. The viewer never sees a target item's quality, wear or power. A renderer shows these and decides nothing. +// A mount row (`Mount` set, `Item` and `Claimed` null) is a hardpoint of the hull the viewer believes the ship to be, with +// nothing of the ship's claimed on it: where something is, not what. public readonly struct SchematicRow { public readonly EquippedItem Item; public readonly EquippableItemData Claimed; + public readonly HardpointData Mount; private readonly bool _own; internal SchematicRow(EquippedItem item, EquippableItemData claimed, bool own) @@ -140,12 +143,21 @@ public readonly struct SchematicRow _own = own; } + internal SchematicRow(HardpointData mount) + { + Mount = mount; + } + // What a renderer compares to know the rows changed. Range is a reading, not part of the key. - public (EquippedItem, EquippableItemData) Key => (Item, Claimed); + public (EquippedItem, EquippableItemData, HardpointData) Key => (Item, Claimed, Mount); + + // Whether this row is the one `aimed` points at: a claimed-item row by its item, a mount row by the mount the aimed + // item sits on (an aimed item with a claimed-item row has no mount row). + public bool Marks(EquippedItem aimed) => Item != null ? Item == aimed : aimed != null && aimed.Hardpoint == Mount; public float? Range => _own ? Item.Behaviors.OfType().First().Range - : Claimed.Behaviors.OfType().FirstOrDefault()?.RatedRange; + : Claimed?.Behaviors.OfType().FirstOrDefault()?.RatedRange; } // A subject's removal from a zone as the zone recorded it (death, dock, warp, any removal): where it was and how diff --git a/Assets/Scripts/UI/HUD/SchematicDisplay.cs b/Assets/Scripts/UI/HUD/SchematicDisplay.cs index 1ac5a23a7..9e2f6d9f1 100644 --- a/Assets/Scripts/UI/HUD/SchematicDisplay.cs +++ b/Assets/Scripts/UI/HUD/SchematicDisplay.cs @@ -123,7 +123,9 @@ public class SchematicDisplay : MonoBehaviour .ToArray(); foreach (var x in _schematicItems) { - if(x.Row.Claimed is WeaponItemData weaponItemData) + if (x.Row.Mount != null) + x.ListElement.ShowGear(x.Row.Mount.Type, null); + else if(x.Row.Claimed is WeaponItemData weaponItemData) x.ListElement.ShowWeapon(weaponItemData); else x.ListElement.ShowGear(x.Row.Claimed.HardpointType, x.Row.Claimed.Name); @@ -208,7 +210,7 @@ public class SchematicDisplay : MonoBehaviour // as info decays, with no event to hook. if (_enemy && _schematicItems != null) foreach (var x in _schematicItems) - x.ListElement.SetSelected(x.Row.Item == _player.ResolvedTargetItem); + x.ListElement.SetSelected(x.Row.Marks(_player.ResolvedTargetItem)); if(VisibilityLabel && !_enemy) VisibilityLabel.text = ((int)_entity.Visibility).ToString();