From f6c0d24dba6617bbea112ffa8c8573c6d4243350 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 15 Oct 2026 04:42:38 +0200 Subject: [PATCH] One aim slot holds an item or a mount; the cycle covers every located mount Entity.AimedAt (AimTarget: a revealed item or a believed mount) replaces TargetItem. Two writers, TrySelectTargetItem (IsRevealed) and TrySelectMount (in LayoutOf, not seen destroyed); ResolvedAim re-validates both on every read. AimCycle is SchematicRows' enemy rows minus mounts in the observer's seen-destroyed record, written only in Sense through PerceivesDestroyed. SchematicRows drops its covering filter. IsLocated and AimsAtMount are deleted; PendingShot.AimedMount and Silhouette's HardpointData mount replace the bool. The cycle key calls Entity.CycleAim; the schematic marks read ResolvedAim. Entity.ToString names an entity (follow-up entity-formats-safely). The slot is AimedAt, not Aim: Entity.Aim is the pilot's pointing direction. Not yet built or run; ActionGameManager and SchematicDisplay are uncompiled without Unity. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 19 +- .../ServerShared/Behaviors/TargetingSystem.cs | 2 +- Assets/Scripts/ServerShared/Entity.cs | 149 +++++++++++---- Assets/Scripts/ServerShared/FireControl.cs | 48 ++--- Assets/Scripts/ServerShared/Sensing.cs | 23 ++- Assets/Scripts/UI/HUD/SchematicDisplay.cs | 4 +- Assets/Scripts/UI/HUD/SchematicListElement.cs | 2 +- .../EntityFormatTests.cs | 27 +++ .../FireControlCut7Tests.cs | 8 +- .../Aetheria.Shared.Tests/FireControlTests.cs | 22 +-- tests/Aetheria.Shared.Tests/LayoutAimTests.cs | 119 ++++++------ .../Aetheria.Shared.Tests/MountCycleTests.cs | 176 ++++++++++++++++++ .../TargetingSystemTests.cs | 12 +- 13 files changed, 449 insertions(+), 162 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/EntityFormatTests.cs create mode 100644 tests/Aetheria.Shared.Tests/MountCycleTests.cs diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 6d1b23dfb..eab9531a1 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -441,22 +441,9 @@ 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 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 located = target.Equipment.Where(x => FireControl.IsLocated(CurrentEntity, x)).ToArray(); - if (located.Length == 0) - { - CurrentEntity.TrySelectTargetItem(null); - return; - } - var currentItemIndex = Array.IndexOf(located, CurrentEntity.ResolvedTargetItem); - CurrentEntity.TrySelectTargetItem(located[(currentItemIndex + 1) % located.Length]); - }; + // Cut 2 (docs/fire-control-cut.md): steps the aim through the target's schematic rows -- every mount this ship + // has located, empty or not, minus the ones it saw destroyed. Decides nothing: the entity owns the cycle. + Input.Player.CycleTargetItem.performed += context => CurrentEntity.CycleAim(); #endregion diff --git a/Assets/Scripts/ServerShared/Behaviors/TargetingSystem.cs b/Assets/Scripts/ServerShared/Behaviors/TargetingSystem.cs index 96d9f3703..b0b839258 100644 --- a/Assets/Scripts/ServerShared/Behaviors/TargetingSystem.cs +++ b/Assets/Scripts/ServerShared/Behaviors/TargetingSystem.cs @@ -31,7 +31,7 @@ public class TargetingSystemData : BehaviorData // Cut 6d (docs/fire-control-cut.md): grouping tightness, not a probability. FireControl.Sigma derives a // Gaussian kernel's sigma as 1/Precision, in hull-schematic cell units -- a higher number groups tighter - // around whatever is aimed at (Entity.TargetItem's cells, or the hull's own centre of mass unaimed). + // around whatever is aimed at (Entity.AimedAt: an item's cells or a mount's; the hull's own centre of mass unaimed). // Consumed by Cut 3's roll (folded into HitProbability as pOnHull) and by Commit's cell draw, both through // the one kernel function -- never re-derived separately. [Inspectable, JsonProperty("precision"), Key(3), RuntimeInspectable] diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 226204133..caaccbb55 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -69,18 +69,23 @@ public abstract class Entity private readonly ReactiveProperty _target = new ReactiveProperty(TargetRef.None); public IReadOnlyReactiveProperty Target => _target; - // 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 (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); + // The one aim slot on the Target (docs/fire-control-cut.md, Cut 2): a revealed item or a mount of the hull this + // observer believes the target to be, never both. Written only by TrySelectTargetItem (a revealed item: Combat's path + // and the player's) and TrySelectMount (a believed mount), and nulled whenever Target changes (subscribed in + // Activate). Do not read it outside Entity -- read ResolvedAim, which re-validates both gates on every call, so decay, + // a perceived destruction, or the item leaving the target drops the aim the moment it is no longer earned, with no + // loop that has to notice and clear it. (Not `Aim`: that is the pilot's pointing direction below.) + public AimTarget AimedAt { get; private set; } // Where the pilot points: reticle, lock cone, tractor, guided rounds, fire direction. Steers nothing; the // hull turns only by Ship.Turn. public float3 Aim; - + public string Name; + + // Names the entity for assertion messages and logs: xunit and the debugger format an object by walking its graph + // unless it has a ToString, and an Entity's graph is the whole zone. Entity has no id; Name is the hull's or the scenario's. + public override string ToString() => $"{GetType().Name} '{Name}'"; // public int Population; // public Dictionary Personality = new Dictionary(); @@ -250,6 +255,10 @@ public abstract class Entity // item). Written only by RecordReveal (an act, or a scan through RecordInspection, enters) and EndReveals (an end the observer perceives, or a departed // subject's entries aged past BearingMemory), cleared by Deactivate (ruling reveal-ends-on-perception). private readonly Dictionary> _reveals = new Dictionary>(); + // The mounts of each subject whose item this observer perceived destroyed (PerceivesDestroyed), so the target cycle + // skips them and no aim rests on them: observer state, written only by RecordSeenDestroyed in Sense, dropped with the + // subject's reveals (EndReveals) and cleared by Deactivate. Truth enters only through the one perception rule. + private readonly Dictionary> _seenDestroyed = new Dictionary>(); private readonly List _endedItems = new List(); private readonly List _agedSubjects = new List(); public ReactiveProperty CurrentSecurityLevel = new ReactiveProperty(SecurityLevel.Open); @@ -383,16 +392,16 @@ public abstract class Entity _watchedEntitySubscriptions.Remove(disappearingEntity.Value); })); - // Cut 2 (docs/fire-control-cut.md): a Target change nulls the aim point. Fires immediately on - // subscribe with whatever Target already holds, which is harmless -- TargetItem starts null anyway. - _subscriptions.Add(Target.Subscribe(_ => TargetItem.Value = null)); + // Cut 2 (docs/fire-control-cut.md): a Target change nulls the aim. Fires immediately on + // subscribe with whatever Target already holds, which is harmless -- AimedAt starts empty anyway. + _subscriptions.Add(Target.Subscribe(_ => AimedAt = AimTarget.None)); // 9.3 (docs/fire-control-cut.md, Soul C3): a Target surviving Deactivate can point at an entity // this zone removed while inactive -- Deactivate disposes the Zone.Entities.ObserveRemove // subscription that is the only thing that nulls a stale Target, and EntityInfoGathered was just // rebuilt above from the zone's *current* membership, not from whatever Target still remembers. // Reconcile here, now that both the fresh EntityInfoGathered and the Target-change subscription - // above are live (so this write also clears TargetItem through it): an active entity's Target + // above are live (so this write also clears the aim through it): an active entity's Target // must always be an entity it has info on. Activate owns this reconciliation rather than // Deactivate nulling Target outright, because nulling on every dock would throw away a target // that is still alive and simply never changed while docked -- the common case, and not the one @@ -407,34 +416,80 @@ public abstract class Entity GenerateWeaponGroups(); } - // 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.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. + // Cut 2 (docs/fire-control-cut.md): one of the aim slot's two writers (the other is TrySelectMount). Accepts null + // (clearing the aim) or a revealed item (FireControl.IsRevealed: this entity saw it act) belonging to the current + // Target. A fitted item the observer has only located is aimed as its mount, never as an item. Returns whether the + // write took effect; a rejected non-null selection leaves the aim unchanged. public bool TrySelectTargetItem(EquippedItem item) { if (item == null) { - TargetItem.Value = null; + AimedAt = AimTarget.None; return true; } // 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.IsLocated(this, item)) + if (Target.Value.Entity == null || item.Entity != Target.Value.Entity || !FireControl.IsRevealed(this, item)) return false; - TargetItem.Value = item; + AimedAt = AimTarget.OfItem(item); return true; } - // The read path for TargetItem (see the field's own comment): null once the aimed item is no longer - // 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.IsLocated(this, TargetItem.Value) - ? TargetItem.Value - : null; + // The aim slot's other writer: a mount of the hull this entity believes the Target to be (LayoutOf, by reference), + // unless this entity perceived that mount's item destroyed (SeenDestroyed). Reads only observer state, so whether a + // mount can be aimed at says nothing of the Target's truth (ruling cycle-skips-only-perceived-destroyed). + public bool TrySelectMount(HardpointData mount) + { + if (!MountAimable(Target.Value.Entity, mount)) return false; + AimedAt = AimTarget.OfMount(mount); + return true; + } + + private bool MountAimable(Entity subject, HardpointData mount) => + subject != null && mount != null && LayoutOf(subject).Contains(mount) && !SeenDestroyed(subject, mount); + + // The read path for the aim slot (see its comment): the aim only while its gate still holds -- an item still revealed + // and still the Target's, a mount still in the believed layout and not seen destroyed -- else none, even though + // nothing wrote the slot at the moment that stopped being true. Re-checked on every call; never cached. + public AimTarget ResolvedAim + { + get + { + var subject = Target.Value.Entity; + var aim = AimedAt; + if (subject == null) return AimTarget.None; + if (aim.Item != null) return aim.Item.Entity == subject && FireControl.IsRevealed(this, aim.Item) ? aim : AimTarget.None; + return MountAimable(subject, aim.Mount) ? aim : AimTarget.None; + } + } + + public EquippedItem ResolvedTargetItem => ResolvedAim.Item; + + // What the cycle visits on `subject`, in schematic order: its rows (revealed items, then every mount of the hull this + // ship believes it to be, empty or not) minus the mounts this ship saw destroyed. Reads the same rows the schematic + // shows and the record, never `subject`'s equipment or live Durability. + public IEnumerable AimCycle(Entity subject) => + SchematicRows(subject) + .Where(r => r.Item != null || !SeenDestroyed(subject, r.Mount)) + .Select(r => r.Item != null ? AimTarget.OfItem(r.Item) : AimTarget.OfMount(r.Mount)); + + // The player's cycle key: steps the aim to the next AimCycle entry after the resolved one and wraps; an entry its + // writer refuses (a revealed item since destroyed) is passed over; nothing to visit clears the aim. + public void CycleAim() + { + var subject = Target.Value.Entity; + if (subject == null) return; + var cycle = AimCycle(subject).ToList(); + var now = ResolvedAim; + var index = cycle.FindIndex(a => a.Item == now.Item && a.Mount == now.Mount && (now.Item != null || now.Mount != null)); + for (var step = 1; step <= cycle.Count; step++) + { + var next = cycle[(index + step + cycle.Count) % cycle.Count]; + if (next.Item != null ? TrySelectTargetItem(next.Item) : TrySelectMount(next.Mount)) return; + } + AimedAt = AimTarget.None; + } // Mining Cut 3 (docs/mining-cut-refresh.md): the one writer of Target, called by the player's handlers and the // agents alike. A chunk is taken only while this entity can see it, so a rock in darkness cannot be picked; an @@ -908,6 +963,7 @@ public abstract class Entity _pingReturns.Clear(); _emissions.Clear(); _reveals.Clear(); + _seenDestroyed.Clear(); _itemClaims.Clear(); } @@ -985,7 +1041,8 @@ public abstract class Entity } // This observer resolves `subject`'s gear (ContactOf's Identified gate) and `item` of it is destroyed: the one rule - // for a destroyed item an observer perceives, read by the book's entry (RecordReveal) and its end (Sense). + // for a destroyed item an observer perceives, read by the book's entry (RecordReveal), its end (Sense) and the + // seen-destroyed record (RecordSeenDestroyed). private bool PerceivesDestroyed(Entity subject, EquippedItem item) => item.EquippableItem.IsDestroyed && EntityInfoGathered.TryGetValue(subject, out var info) && info >= ItemManager.GameplaySettings.TargetGearInfoThreshold; @@ -998,10 +1055,30 @@ public abstract class Entity if (subject.Parent == null && !Zone.Entities.Contains(subject) && !_reveals.ContainsKey(subject)) _iffOverrides.Remove(subject); } + // This observer perceives `subject`'s gear (ContactOf's Identified gate): each of its destroyed items on a mount of the + // hull this ship believes it to be (LayoutOf, by reference) or in the book leaves its mount in the seen-destroyed + // record. The same PerceivesDestroyed Sense ends reveals by, applied to located mounts. + private void RecordSeenDestroyed(Entity subject) + { + _reveals.TryGetValue(subject, out var book); + var layout = LayoutOf(subject); + foreach (var item in subject.Equipment) + { + if (item.Hardpoint == null || !PerceivesDestroyed(subject, item)) continue; + if (!layout.Contains(item.Hardpoint) && (book == null || !book.ContainsKey(item))) continue; + if (!_seenDestroyed.TryGetValue(subject, out var seen)) _seenDestroyed[subject] = seen = new HashSet(); + seen.Add(item.Hardpoint); + } + } + + private bool SeenDestroyed(Entity subject, HardpointData mount) => + _seenDestroyed.TryGetValue(subject, out var seen) && seen.Contains(mount); + // The book's one way out (caller: Sense): `item` null ends every reveal of `subject`, otherwise that item's. - // The subject's key goes with its last entry. + // The subject's key goes with its last entry; ending all of them also drops the subject's seen-destroyed record. private void EndReveals(Entity subject, EquippedItem item) { + if (item == null) _seenDestroyed.Remove(subject); if (!_reveals.TryGetValue(subject, out var book)) return; if (item != null) book.Remove(item); else book.Clear(); @@ -1060,17 +1137,16 @@ 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), 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. + // row for every mount of the hull this ship believes `subject` to be (LayoutOf), empty or not and whether or not a + // revealed item has a row of its own (ruling quiet-gear-identifiable-hull-layout-known): the rows never compare the + // subject's items to the believed layout, so a liar and an honest ship of the claimed hull show the same count. 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))); + return claimed.Concat(LayoutOf(subject).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 @@ -1138,8 +1214,9 @@ public abstract class Entity if (planar <= emission.HearRange * hearing * obscuration) RecordReveal(other, emission.Item, SignatureChannel.Emission, emission.Claimed); } } - // The observer resolves this ship's gear (PerceivesDestroyed), so it perceives an item of it destroyed and - // that reveal ends here; RecordReveal refuses the same item's lingering flash. + // The observer resolves this ship's gear (PerceivesDestroyed), so it perceives an item of it destroyed: the + // item's mount is recorded and its reveal ends here; RecordReveal refuses the same item's lingering flash. + RecordSeenDestroyed(other); if (_reveals.TryGetValue(other, out var book)) { foreach (var item in book.Keys) diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 041c6b2ff..e2f23dd29 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -157,24 +157,10 @@ public static class FireControl // 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. 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. +XX 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. @@ -352,7 +338,8 @@ public static class FireControl Silhouette sil; try { - sil = Silhouette(target, targetHull, source.ResolvedTargetItem, bearing, precision, buffer, AimsAtMount(source, source.ResolvedTargetItem)); + var aim = source.ResolvedAim; + sil = Silhouette(target, targetHull, aim.Item, bearing, precision, buffer, aim.Mount); var settings = source.ItemManager.GameplaySettings; pSpread = PSpread(weapon.Spread, sil.Span, range, settings.SchematicCellSize); } @@ -731,14 +718,15 @@ public static class FireControl var commitHorizon = source.ItemManager.GameplaySettings.CommitHorizon; + var aim = source.ResolvedAim; var shot = new PendingShot { ShotId = zone.NextShotId(), Source = source, Target = engaged, Weapon = item, - Aimed = source.ResolvedTargetItem, - AimAtMount = AimsAtMount(source, source.ResolvedTargetItem), + Aimed = aim.Item, + AimedMount = aim.Mount, Damage = damageOverride ?? weapon.Damage, FireMinRange = weapon.MinRange, FireEffectiveRange = weapon.EffectiveRange, @@ -911,7 +899,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, null, shot.AimAtMount); + sil = Silhouette(shot.Target, hullData, shot.Aimed, bearing, shot.Precision, null, shot.AimedMount); var settings = shot.Target.ItemManager.GameplaySettings; return p * PSpread(shot.Spread, sil.Span, shot.FireRange, settings.SchematicCellSize) * sil.POnHull; } @@ -1778,13 +1766,13 @@ public static class FireControl private const float SigmaFloor = 0.5f; 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 (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, bool atMount) + // The aim point a silhouette is centred on (R10/Q6: the same frozen Aimed and AimedMount both HitProbability and Fire + // read) -- the cells of the mount aimed at when the aim was frozen at a mount (the shooter has not learned what is + // fitted there, so never the fitted item's cells), else the aimed item's own cell centroid when it currently occupies + // cells on the target, 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, HardpointData mount) { - var aimedCells = aimed == null ? null : atMount && aimed.Hardpoint != null ? MountCells(aimed.Hardpoint) : CellsOf(target, aimed); + var aimedCells = mount != null ? MountCells(mount) : aimed == null ? null : 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; @@ -1804,7 +1792,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, bool atMount = false) + public static Silhouette Silhouette(Entity target, HullData hull, EquippedItem aimed, float2 bearing, float precision, Interval[] buffer = null, HardpointData mount = null) { var ell = Lateral(bearing); var coords = hull.Shape.Coordinates; @@ -1826,7 +1814,7 @@ public static class FireControl } var sigma = Sigma(precision); - var aimPoint = ResolveAimPoint(target, hull, aimed, atMount); + var aimPoint = ResolveAimPoint(target, hull, aimed, mount); var a = dot(aimPoint, ell); var pOnHull = 0f; @@ -2009,9 +1997,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 mount the aim was at, when it was at a mount rather than an item (Entity.ResolvedAim at Fire), frozen with Aimed: + // a reveal between Fire and Commit does not move where this shot was aimed. Null for an item aim or none. + public HardpointData AimedMount; // 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 7ed482a14..9de4fae0a 100644 --- a/Assets/Scripts/ServerShared/Sensing.cs +++ b/Assets/Scripts/ServerShared/Sensing.cs @@ -122,6 +122,24 @@ public readonly struct Reveal } } +// What an observer aims at on its target (Entity.Aim): exactly one of a revealed item or a mount of the hull the observer +// believes the target to be (Entity.LayoutOf), or nothing. A mount is a believed position, never the true item on it. +public readonly struct AimTarget +{ + public readonly EquippedItem Item; + public readonly HardpointData Mount; + + private AimTarget(EquippedItem item, HardpointData mount) + { + Item = item; + Mount = mount; + } + + public static AimTarget None => default; + public static AimTarget OfItem(EquippedItem item) => new AimTarget(item, null); + public static AimTarget OfMount(HardpointData mount) => new AimTarget(null, mount); +} + // One weapon or gear row of a ship's schematic as its viewer is entitled to read it (aetheria-ew, classify-from-belief; ruling // ew-claim-weapon-measure-ruled). Made only by Entity.SchematicRows. `Item` is the true item: the selection mark's key and, // on the viewer's own ship, the live gear. `Claimed` is the class the row shows. The range label is the live range on @@ -152,9 +170,8 @@ public readonly struct SchematicRow // What a renderer compares to know the rows changed. Range is a reading, not part of the key. 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; + // Whether this row is the one `aim` points at: a claimed-item row by its item, a mount row by its mount. + public bool Marks(AimTarget aim) => Item != null ? aim.Item == Item : aim.Mount != null && aim.Mount == Mount; public float? Range => _own ? Item.Behaviors.OfType().First().Range diff --git a/Assets/Scripts/UI/HUD/SchematicDisplay.cs b/Assets/Scripts/UI/HUD/SchematicDisplay.cs index 9e2f6d9f1..57f545960 100644 --- a/Assets/Scripts/UI/HUD/SchematicDisplay.cs +++ b/Assets/Scripts/UI/HUD/SchematicDisplay.cs @@ -206,11 +206,11 @@ public class SchematicDisplay : MonoBehaviour } // Cut 2 (docs/fire-control-cut.md): marks whichever row is the player's current aim point. - // Re-checked every frame (not just on ShowShip) because ResolvedTargetItem can drop on its own + // Re-checked every frame (not just on ShowShip) because ResolvedAim can drop on its own // as info decays, with no event to hook. if (_enemy && _schematicItems != null) foreach (var x in _schematicItems) - x.ListElement.SetSelected(x.Row.Marks(_player.ResolvedTargetItem)); + x.ListElement.SetSelected(x.Row.Marks(_player.ResolvedAim)); if(VisibilityLabel && !_enemy) VisibilityLabel.text = ((int)_entity.Visibility).ToString(); diff --git a/Assets/Scripts/UI/HUD/SchematicListElement.cs b/Assets/Scripts/UI/HUD/SchematicListElement.cs index e5433c52f..b42bf4632 100644 --- a/Assets/Scripts/UI/HUD/SchematicListElement.cs +++ b/Assets/Scripts/UI/HUD/SchematicListElement.cs @@ -15,7 +15,7 @@ public class SchematicListElement : MonoBehaviour public RectTransform CooldownFill; public RectTransform HeatFill; - // Cut 2 (docs/fire-control-cut.md): marks this row as the current aim point (Entity.ResolvedTargetItem) + // Cut 2 (docs/fire-control-cut.md): marks this row as the current aim point (Entity.ResolvedAim) // on an enemy schematic. Unassigned in a prefab that has not been wired up in the Unity editor yet // (unavailable in this pass) -- SetSelected below is a no-op until then, not a null reference. public GameObject SelectedIndicator; diff --git a/tests/Aetheria.Shared.Tests/EntityFormatTests.cs b/tests/Aetheria.Shared.Tests/EntityFormatTests.cs new file mode 100644 index 000000000..e7e3a39bc --- /dev/null +++ b/tests/Aetheria.Shared.Tests/EntityFormatTests.cs @@ -0,0 +1,27 @@ +using System.Linq; +using Xunit; +using Xunit.Sdk; +using static CultMath.math; + +// follow-up entity-formats-safely: a failing assertion over an Entity reports it by name. xunit formats an object by walking +// its graph unless the object has a ToString, and an Entity's graph is the whole zone, which overflowed the test host on +// every failing Assert over an Entity. +public sealed partial class ContactRevealTests +{ + [Fact] + public void EntityFormatsByName() + { + var bay = AddBay(float3(0, 0, 100), gun: true); + var other = Add(float3(0, 0, 0)); + + var entities = Assert.ThrowsAny(() => Assert.Same(bay, other)); + Assert.Contains("Ship 'Bay'", entities.Message); + Assert.Contains("Ship 'Rack'", entities.Message); + Assert.True(entities.Message.Length < 2000, $"the message is {entities.Message.Length} characters"); + + var gun = Guns(bay).Single(); + var reactor = Fitted(bay, "BayReactor"); + var items = Assert.ThrowsAny(() => Assert.Same(gun, reactor)); + Assert.True(items.Message.Length < 2000, $"the message is {items.Message.Length} characters"); + } +} diff --git a/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs index 1984e7b74..01bd840ee 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs @@ -17,7 +17,7 @@ using int2 = CultMath.int2; using Random = CultMath.Random; // Cut 7 (docs/fire-control-cut.md): 7.1, the fifth mutation Soul found survived 197 green tests -- -// FireControl.Fire freezing the raw TargetItem.Value instead of the reveal-re-checked ResolvedTargetItem. +// FireControl.Fire freezing the raw AimedAt.Item instead of the reveal-re-checked ResolvedTargetItem. // Builds its own shooter/target fixture, the same shape FireAuthorityTests uses for Cut 3, rather than // reusing that file's private Build helper (the established convention in this campaign's test files). public sealed class FireControlCut7Tests : IDisposable @@ -144,8 +144,8 @@ public sealed class FireControlCut7Tests : IDisposable } // 7.1 (Soul's fifth survivor): FireControl.Fire must freeze source.ResolvedTargetItem, not the raw - // source.TargetItem.Value -- the aim point is re-checked against reveal at the instant the trigger is - // pulled, same as every other read of it. Mutation: read TargetItem.Value directly. Must die. + // source.AimedAt.Item -- the aim point is re-checked against reveal at the instant the trigger is + // pulled, same as every other read of it. Mutation: read AimedAt.Item directly. Must die. // // The observer saw the target's interior item act and aims at it; the item is then destroyed. The shot is // still worth taking -- this is no blackout -- but the aim point is re-checked at the instant the trigger is @@ -172,7 +172,7 @@ public sealed class FireControlCut7Tests : IDisposable // Destroyed: the raw field still holds the item -- nothing clears it -- but the read accessor returns null. aimedGear.Durability = 0f; - Assert.Same(aimedItem, e.Shooter.TargetItem.Value); + Assert.Same(aimedItem, e.Shooter.AimedAt.Item); Assert.Null(e.Shooter.ResolvedTargetItem); var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); diff --git a/tests/Aetheria.Shared.Tests/FireControlTests.cs b/tests/Aetheria.Shared.Tests/FireControlTests.cs index d4d0ba50d..d491b16e8 100644 --- a/tests/Aetheria.Shared.Tests/FireControlTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlTests.cs @@ -274,19 +274,19 @@ public sealed class FireControlTests : IDisposable zone.Update(1f); zone.Update(1f); - Assert.Null(shooter.TargetItem.Value); + Assert.Null(shooter.AimedAt.Item); shooter.RecordReveal(target, targetGun, SignatureChannel.LaunchFlash, targetGun.Data); zone.Update(1f); - Assert.Same(targetGun, shooter.TargetItem.Value); + Assert.Same(targetGun, shooter.AimedAt.Item); // The gun is destroyed: nothing qualifies any more, so Combat clears the aim point itself rather than // leaving the dead gun in the raw field. targetGun.EquippableItem.Durability = 0f; zone.Update(1f); - Assert.Null(shooter.TargetItem.Value); + Assert.Null(shooter.AimedAt.Item); } // A design as an observer might be told it is: an instant weapon with rated figures, held in memory (a claim is data, @@ -321,12 +321,12 @@ public sealed class FireControlTests : IDisposable shooter.RecordReveal(target, targetGun, SignatureChannel.LaunchFlash, target.ClaimOf(targetGun)); zone.Update(1f); zone.Update(1f); - Assert.Null(shooter.TargetItem.Value); + Assert.Null(shooter.AimedAt.Item); target.Claim(targetGun, null); shooter.RecordReveal(target, targetGun, SignatureChannel.LaunchFlash, target.ClaimOf(targetGun)); zone.Update(1f); - Assert.Same(targetGun, shooter.TargetItem.Value); + Assert.Same(targetGun, shooter.AimedAt.Item); } // Ruling ew-claim-weapon-measure-ruled (rated-design): the AI ranks revealed weapons by their claimed design's @@ -348,12 +348,12 @@ public sealed class FireControlTests : IDisposable } See(); - Assert.Same(strong, shooter.TargetItem.Value); // unclaimed: the truly stronger gun (rated 10 against 1) + Assert.Same(strong, shooter.AimedAt.Item); // unclaimed: the truly stronger gun (rated 10 against 1) target.Claim(strong, ClaimedDesign("Pea", 2f)); target.Claim(weak, ClaimedDesign("Cannon", 90f)); See(); - Assert.Same(weak, shooter.TargetItem.Value); + Assert.Same(weak, shooter.AimedAt.Item); } // The ranking reads the claimed design at the distance to the target: a short-range design that out-damages a flat one @@ -373,7 +373,7 @@ public sealed class FireControlTests : IDisposable zone.Update(1f); zone.Update(1f); - Assert.Same(weak, shooter.TargetItem.Value); + Assert.Same(weak, shooter.AimedAt.Item); } // The distance Combat measures is the gate's: planar (FireControl R7). Zone depth puts the target above the shooter's @@ -398,7 +398,7 @@ public sealed class FireControlTests : IDisposable zone.Update(1f); zone.Update(1f); - Assert.Same(strong, shooter.TargetItem.Value); + Assert.Same(strong, shooter.AimedAt.Item); } // The AI settles where its guns do the most damage per second, weighted towards range: with full damage to 500 and none @@ -451,7 +451,7 @@ public sealed class FireControlTests : IDisposable var liveStrong = strong.Behaviors.OfType().Single().RangeDamagePerSecond(10f); var liveWeak = weak.Behaviors.OfType().Single().RangeDamagePerSecond(10f); Assert.True(liveStrong < liveWeak); // the fixture: live and rated disagree - Assert.Same(strong, shooter.TargetItem.Value); + Assert.Same(strong, shooter.AimedAt.Item); } // Ties are explicit: two revealed weapons of equal rated figure are taken in the subject's equipment order, whatever @@ -473,7 +473,7 @@ public sealed class FireControlTests : IDisposable zone.Update(1f); Assert.Equal(new[] { first, second }, shooter.RevealedWeapons(target).Select(r => r.Item).ToArray()); - Assert.Same(first, shooter.TargetItem.Value); + Assert.Same(first, shooter.AimedAt.Item); } // Controls cut (helm split): Combat aims the shooter at the intercept (Aim) and commands the hull toward it diff --git a/tests/Aetheria.Shared.Tests/LayoutAimTests.cs b/tests/Aetheria.Shared.Tests/LayoutAimTests.cs index 072942184..18336b804 100644 --- a/tests/Aetheria.Shared.Tests/LayoutAimTests.cs +++ b/tests/Aetheria.Shared.Tests/LayoutAimTests.cs @@ -95,51 +95,57 @@ public sealed partial class ContactRevealTests Assert.Empty(observer.LayoutOf(bay)); } - // ---- TheReactorIsLocatedNotRevealed ---- + // ---- TheReactorMountIsAimedNotRevealed ---- - // Identified, the reactor never acted: the observer has located it (can aim at it) and learned nothing of it. Below the - // identify threshold it can aim at nothing, and a mount aim already taken is dropped when the identification is lost. + // Identified, the reactor never acted: the observer has located its mount (can aim at it) and learned nothing of the + // item. Below the identify threshold it can aim at no mount, and a mount aim already taken is dropped when the + // identification is lost. An unrevealed item is never an item aim. [Fact] - public void TheReactorIsLocatedNotRevealed() + public void TheReactorMountIsAimedNotRevealed() { var (bay, observer, reactor) = Identified(Identify - .01f); Target(observer, bay); - Assert.False(FireControl.IsLocated(observer, reactor)); - Assert.False(observer.TrySelectTargetItem(reactor)); + Assert.False(observer.TrySelectMount(reactor.Hardpoint)); observer.EntityInfoGathered[bay] = Identify; - Assert.True(FireControl.IsLocated(observer, reactor)); Assert.False(FireControl.IsRevealed(observer, reactor)); - Assert.True(observer.TrySelectTargetItem(reactor)); - Assert.True(ReferenceEquals(reactor, observer.ResolvedTargetItem), "the located reactor is the aim point"); + Assert.False(observer.TrySelectTargetItem(reactor), "an unrevealed item is not an item aim"); + Assert.True(observer.TrySelectMount(reactor.Hardpoint)); + Assert.True(ReferenceEquals(reactor.Hardpoint, observer.ResolvedAim.Mount), "the located reactor mount is the aim"); + Assert.True(observer.ResolvedAim.Item == null && observer.ResolvedTargetItem == null, "a mount aim names no item"); observer.EntityInfoGathered[bay] = Identify - .01f; - Assert.True(observer.ResolvedTargetItem == null, "the aim is dropped with the identification"); + Assert.True(observer.ResolvedAim.Mount == null, "the aim is dropped with the identification"); } - // ---- ADestroyedMountItemIsNotLocated ---- + // ---- AnUnperceivedDestructionLeavesTheMountAimed ---- + // The observer perceives a destruction only through its own Sense: the reactor destroyed unseen leaves its mount aimed + // (a live read of the item's Durability would tell the observer what it has not sensed); once Sensed it is gone. [Fact] - public void ADestroyedMountItemIsNotLocated() + public void AnUnperceivedDestructionLeavesTheMountAimed() { var (bay, observer, reactor) = Identified(); Target(observer, bay); - Assert.True(observer.TrySelectTargetItem(reactor), "fixture: the live reactor is located and aimed at"); + Assert.True(observer.TrySelectMount(reactor.Hardpoint), "fixture: the reactor mount is aimed at"); reactor.EquippableItem.Durability = 0f; Assert.True(reactor.EquippableItem.IsDestroyed, "fixture"); - Assert.False(FireControl.IsLocated(observer, reactor)); - Assert.True(observer.ResolvedTargetItem == null); - Assert.False(observer.TrySelectTargetItem(reactor)); + Assert.True(ReferenceEquals(reactor.Hardpoint, observer.ResolvedAim.Mount), "nothing sensed yet: the aim stands"); + Assert.True(observer.TrySelectMount(reactor.Hardpoint)); + + SenseOf(observer)(); + Assert.True(observer.ResolvedAim.Mount == null, "perceived destroyed: the aim goes"); + Assert.False(observer.TrySelectMount(reactor.Hardpoint)); } - // ---- UnderAHullLieNoGearIsLocated ---- + // ---- UnderAHullLieNoGearIsAimed ---- // Ruling ew-aim-under-hull-lie-ruled: while the lie is believed the observer's layout is the claimed hull's and the true - // reactor sits on none of its mounts, so no gear can be aimed at and the ship stays targetable; gear aim opens when the - // lie is seen through. + // reactor sits on none of its mounts, so the true item is never an aim: the claimed mount is, and the true reactor's own + // mount is not; the ship stays targetable. The true mount is aimable again when the lie is seen through. [Fact] - public void UnderAHullLieNoGearIsLocated() + public void UnderAHullLieNoGearIsAimed() { var (bay, observer, reactor) = Identified(); var decoy = new HullData { Name = "Decoy", Hardpoints = { new HardpointData { Type = HardpointType.Reactor, Position = new int2(0, 0), Shape = new Shape() } } }; @@ -147,21 +153,23 @@ public sealed partial class ContactRevealTests Assert.True(decoy.Hardpoints.SequenceEqual(observer.LayoutOf(bay)) && observer.LayoutOf(bay).Count == 1, "the layout is the claimed hull's"); Target(observer, bay); - Assert.False(FireControl.IsLocated(observer, reactor)); - Assert.False(observer.TrySelectTargetItem(reactor)); + Assert.False(observer.TrySelectTargetItem(reactor), "the true reactor is not an item aim"); + Assert.False(observer.TrySelectMount(reactor.Hardpoint), "nor is its true mount, which the observer does not believe in"); + Assert.True(observer.TrySelectMount(decoy.Hardpoints[0]), "the claimed mount is"); + Assert.True(ReferenceEquals(decoy.Hardpoints[0], observer.ResolvedAim.Mount)); Assert.True(observer.ResolvedTargetItem == null); Assert.True(ReferenceEquals(bay, observer.Target.Value.Entity), "the ship stays targetable"); bay.Claim(null); - Assert.True(FireControl.IsLocated(observer, reactor)); - Assert.True(observer.TrySelectTargetItem(reactor)); + Assert.True(observer.ResolvedAim.Mount == null, "the claimed mount is not the believed hull's once the lie is seen through"); + Assert.True(observer.TrySelectMount(reactor.Hardpoint)); } // The lie is a lookalike: the claimed hull is of the true hull's type and size, with a reactor mount of the same type, // position and shape as the true one, and a shield mount elsewhere. What the observer knows is the claimed layout, mount // object by mount object; nothing of the true hull's layout leaks in because the two are alike. [Fact] - public void UnderALookalikeLieNoGearIsLocated() + public void UnderALookalikeLieNoGearIsAimed() { var (bay, observer, reactor) = Identified(); var truth = bay.HullData; @@ -181,17 +189,18 @@ public sealed partial class ContactRevealTests Assert.True(lookalike.Hardpoints.SequenceEqual(observer.LayoutOf(bay)), "the layout is the claimed hull's, not the true hull's"); Target(observer, bay); - Assert.False(FireControl.IsLocated(observer, reactor)); Assert.False(observer.TrySelectTargetItem(reactor)); + Assert.False(observer.TrySelectMount(reactor.Hardpoint), "the true reactor mount is not the claimed one, though they look alike"); + Assert.True(observer.TrySelectMount(lookalike.Hardpoints[0])); Assert.True(ReferenceEquals(bay, observer.Target.Value.Entity), "the ship stays targetable"); Assert.Equal(2, observer.SchematicRows(bay).Count(r => r.Mount != null)); } - // ---- ToolGearIsNotLocatedByLayout ---- + // ---- ToolGearIsNotAimedByLayout ---- - // Tool gear sits on no mount, so the layout locates nothing of it; an act does. + // Tool gear sits on no mount, so the layout offers nothing of it to aim at; an act reveals it, and then it is an item aim. [Fact] - public void ToolGearIsNotLocatedByLayout() + public void ToolGearIsNotAimedByLayout() { var bay = AddBay(float3(0, 0, 100), tools: new[] { "Radiator" }); var observer = Add(float3(0, 0, 0)); @@ -200,23 +209,24 @@ public sealed partial class ContactRevealTests Assert.True(radiator.Hardpoint == null, "fixture: a tool item sits on no mount"); Target(observer, bay); - Assert.False(FireControl.IsLocated(observer, radiator)); Assert.False(observer.TrySelectTargetItem(radiator)); + Assert.DoesNotContain(observer.AimCycle(bay), a => a.Item != null); observer.RecordReveal(bay, radiator, SignatureChannel.LaunchFlash, radiator.Data); - Assert.True(FireControl.IsLocated(observer, radiator)); + Assert.True(observer.TrySelectTargetItem(radiator)); + Assert.Contains(observer.AimCycle(bay), a => ReferenceEquals(a.Item, radiator)); } // ---- AMountAimIsTheMountsCells ---- - // A shooter that holds a valid solution on a Bay 30 ahead, with the reactor selected as the aim point. + // A shooter that holds a valid solution on a Bay 30 ahead, with the reactor's mount selected as the aim. private (Ship shooter, Ship bay, EquippedItem reactor, Weapon weapon, EquippedItem gun) Gunner() { var bay = AddBay(float3(0, 0, 30)); return GunnerOn(bay, Fitted(bay, "BayReactor")); } - // A shooter that holds a valid solution on `bay`, with `aimed` (fitted to it) selected as the aim point. + // A shooter that holds a valid solution on `bay`, with the mount of `aimed` (fitted to it) selected as the aim. private (Ship shooter, Ship bay, EquippedItem reactor, Weapon weapon, EquippedItem gun) GunnerOn(Ship bay, EquippedItem aimed) { var shooter = Add(float3(0, 0, 0), new[] { "Eye", "Gun" }); @@ -224,7 +234,7 @@ public sealed partial class ContactRevealTests for (var i = 0; i < 3; i++) Hold(shooter, 1, 1f, bay); Assert.True(shooter.VisibleEntities.Contains(bay), "fixture: the shooter tracks the ship"); var gun = Guns(shooter).Single(); - Assert.True(shooter.TrySelectTargetItem(aimed), "fixture: the located item is aimed at"); + Assert.True(shooter.TrySelectMount(aimed.Hardpoint), "fixture: the mount is aimed at"); return (shooter, bay, aimed, (Weapon) gun.Behaviors.Single(b => b is Weapon), gun); } @@ -246,7 +256,7 @@ public sealed partial class ContactRevealTests var located = FireControl.Inspect(weapon, shooter, bay); var shot = FireAt(weapon, gun, shooter); - Assert.True(shot.AimAtMount && ReferenceEquals(reactor, shot.Aimed), "the shot is aimed at the located reactor's mount"); + Assert.True(ReferenceEquals(reactor.Hardpoint, shot.AimedMount) && shot.Aimed == null, "the shot is aimed at the located reactor's mount, not the item"); Assert.True(FireControl.CommitProbability(shot, _zone.Time, out var mountSil) > 0f, "fixture: the shot has a price"); Assert.Equal(3.5f, mountSil.AimPoint.x, 4); Assert.Equal(3.5f, mountSil.AimPoint.y, 4); @@ -254,9 +264,10 @@ public sealed partial class ContactRevealTests shooter.RecordReveal(bay, reactor, SignatureChannel.LaunchFlash, reactor.Data); Assert.True(FireControl.IsRevealed(shooter, reactor), "fixture: the reactor is revealed"); + Assert.True(shooter.TrySelectTargetItem(reactor), "fixture: the revealed reactor is aimed at as an item"); var revealed = FireControl.Inspect(weapon, shooter, bay); var next = FireAt(weapon, gun, shooter); - Assert.False(next.AimAtMount); + Assert.True(next.AimedMount == null && ReferenceEquals(reactor, next.Aimed), "the next shot is aimed at the item"); Assert.True(FireControl.CommitProbability(next, _zone.Time, out var itemSil) > 0f, "fixture: the shot has a price"); Assert.Equal(3f, itemSil.AimPoint.x, 4); Assert.Equal(3f, itemSil.AimPoint.y, 4); @@ -272,7 +283,7 @@ public sealed partial class ContactRevealTests var (wing, _, gunB) = AddWing(float3(0, 0, 30)); var (shooter, _, _, weapon, gun) = GunnerOn(wing, gunB); var shot = FireAt(weapon, gun, shooter); - Assert.True(shot.AimAtMount && ReferenceEquals(gunB, shot.Aimed), "fixture: the shot is aimed at the located gun's mount"); + Assert.True(ReferenceEquals(gunB.Hardpoint, shot.AimedMount), "fixture: the shot is aimed at the located gun's mount"); Assert.True(FireControl.CommitProbability(shot, _zone.Time, out var sil) > 0f, "fixture: the shot has a price"); Assert.Equal(2.5f, sil.AimPoint.x, 4); Assert.Equal(3f, sil.AimPoint.y, 4); @@ -285,10 +296,11 @@ public sealed partial class ContactRevealTests { var (shooter, bay, reactor, weapon, gun) = Gunner(); var shot = FireAt(weapon, gun, shooter); - Assert.True(shot.AimAtMount, "fixture: fired at the mount"); + Assert.True(ReferenceEquals(reactor.Hardpoint, shot.AimedMount), "fixture: fired at the mount"); shooter.RecordReveal(bay, reactor, SignatureChannel.LaunchFlash, reactor.Data); Assert.True(FireControl.IsRevealed(shooter, reactor), "fixture: revealed before Commit"); + Assert.True(shooter.TrySelectTargetItem(reactor), "fixture: and aimed at as an item, which does not move a shot already fired"); Assert.True(FireControl.CommitProbability(shot, _zone.Time, out var sil) > 0f, "fixture: the shot has a price"); Assert.Equal(3.5f, sil.AimPoint.x, 4); @@ -298,8 +310,8 @@ public sealed partial class ContactRevealTests // ---- TheSchematicListsUnfilledMounts ---- // Identified, nothing revealed: a mount row for each hardpoint, naming no item and no class and giving no range. After a - // scan the reactor's mount row gives way to its gear row. The aimed reactor is marked on whichever row stands for it, and - // no other row is. + // scan the reactor's gear row leads and every mount keeps its row, the reactor's included (rows never compare the true + // items to the believed layout). The aimed mount is marked on its row alone. [Fact] public void TheSchematicListsUnfilledMounts() { @@ -319,16 +331,19 @@ public sealed partial class ContactRevealTests }); Target(observer, bay); - Assert.True(observer.TrySelectTargetItem(reactor)); - Assert.Equal(new[] { true, false, false }, rows.Select(r => r.Marks(observer.ResolvedTargetItem)).ToArray()); + Assert.True(observer.TrySelectMount(reactor.Hardpoint)); + Assert.Equal(new[] { true, false, false }, rows.Select(r => r.Marks(observer.ResolvedAim)).ToArray()); observer.RecordInspection(bay); rows = observer.SchematicRows(bay).ToArray(); - Assert.Equal(3, rows.Length); - Assert.Equal(3, rows.Select(r => r.Key).Distinct().Count()); + Assert.Equal(4, rows.Length); + Assert.Equal(4, rows.Select(r => r.Key).Distinct().Count()); Assert.True(rows[0].Mount == null && ReferenceEquals(rows[0].Item, reactor), "the reactor's gear row leads"); - Assert.True(hardpoints.Skip(1).SequenceEqual(rows.Skip(1).Select(r => r.Mount)), "the other two mounts stay mount rows"); - Assert.Equal(new[] { true, false, false }, rows.Select(r => r.Marks(observer.ResolvedTargetItem)).ToArray()); + Assert.True(hardpoints.SequenceEqual(rows.Skip(1).Select(r => r.Mount)), "every mount keeps its mount row, the reactor's too"); + Assert.Equal(new[] { false, true, false, false }, rows.Select(r => r.Marks(observer.ResolvedAim)).ToArray()); + + Assert.True(observer.TrySelectTargetItem(reactor)); + Assert.Equal(new[] { true, false, false, false }, rows.Select(r => r.Marks(observer.ResolvedAim)).ToArray()); } // Two mounts of one type: the rows tell them apart by mount, not by type. Aiming at one marks its row alone, and revealing @@ -342,19 +357,19 @@ public sealed partial class ContactRevealTests var mountA = gunA.Hardpoint; var mountB = gunB.Hardpoint; Target(observer, wing); - Assert.True(observer.TrySelectTargetItem(gunB)); + Assert.True(observer.TrySelectMount(mountB)); var rows = observer.SchematicRows(wing).ToArray(); Assert.Equal(2, rows.Length); Assert.True(ReferenceEquals(mountA, rows[0].Mount) && ReferenceEquals(mountB, rows[1].Mount), "one mount row per mount, in order"); - Assert.Equal(new[] { false, true }, rows.Select(r => r.Marks(observer.ResolvedTargetItem)).ToArray()); + Assert.Equal(new[] { false, true }, rows.Select(r => r.Marks(observer.ResolvedAim)).ToArray()); observer.RecordReveal(wing, gunA, SignatureChannel.LaunchFlash, gunA.Data); rows = observer.SchematicRows(wing).ToArray(); - Assert.Equal(2, rows.Length); + Assert.Equal(3, rows.Length); Assert.True(ReferenceEquals(rows[0].Item, gunA) && rows[0].Mount == null, "the revealed gun's row leads"); - Assert.True(ReferenceEquals(rows[1].Mount, mountB) && rows[1].Item == null, "the other mount of the same type stays a mount row"); - Assert.Equal(new[] { false, true }, rows.Select(r => r.Marks(observer.ResolvedTargetItem)).ToArray()); + Assert.True(ReferenceEquals(rows[1].Mount, mountA) && ReferenceEquals(rows[2].Mount, mountB), "both mounts keep their mount rows"); + Assert.Equal(new[] { false, false, true }, rows.Select(r => r.Marks(observer.ResolvedAim)).ToArray()); } // ---- TheAIStillChoosesOnlyRevealedWeapons ---- @@ -371,7 +386,7 @@ public sealed partial class ContactRevealTests ai.SetTarget(bay); Hold(ai, 12, 1f, bay); Assert.True(ReferenceEquals(bay, ai.Target.Value.Entity), "fixture: the AI keeps the ship as its target"); - Assert.True(FireControl.IsLocated(ai, gun) && !FireControl.IsRevealed(ai, gun), "fixture: the gun is located and not revealed"); + Assert.False(FireControl.IsRevealed(ai, gun), "fixture: the gun is not revealed"); Assert.True(ai.ResolvedTargetItem == null, "Combat aims at nothing it has not seen act"); Fire(bay, gun); diff --git a/tests/Aetheria.Shared.Tests/MountCycleTests.cs b/tests/Aetheria.Shared.Tests/MountCycleTests.cs new file mode 100644 index 000000000..662606cc8 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/MountCycleTests.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CultMath; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; + +// Mount cycle (rulings cycle-skips-only-perceived-destroyed, quiet-gear-identifiable-hull-layout-known, reveal-ends-on-perception, +// same-ship-same-inputs): the player's target-item cycle visits every mount the observer has located on its target, empty or +// not, and skips only the mounts whose item it perceived destroyed. What the cycle holds depends on the observer's own +// state -- its identification of the ship, its book, the destructions it sensed -- and on nothing the target alone knows: +// a ship lying about its hull and an honest ship of the claimed hull offer the same cycle. Fixtures are LayoutAimTests' +// (Bay: reactor mount, shield mount, sensors mount; Wing: two sensors mounts). Hardpoints and items are compared with +// ReferenceEquals, as there. +public sealed partial class ContactRevealTests +{ + private static HardpointData[] MountsOf(IEnumerable cycle) => cycle.Where(a => a.Mount != null).Select(a => a.Mount).ToArray(); + + private static bool SameMounts(HardpointData[] a, HardpointData[] b) => a.Length == b.Length && a.Zip(b, (x, y) => ReferenceEquals(x, y)).All(same => same); + + // Whether a cycle entry is exactly `item` or exactly `mount`, by reference. + private static bool Is(AimTarget aim, EquippedItem item, HardpointData mount) => ReferenceEquals(aim.Item, item) && ReferenceEquals(aim.Mount, mount); + + // ---- TheCycleCoversEveryLocatedMount ---- + + // An identified honest Bay with its gun seen firing and its shield mount empty. The cycle lists the revealed gun, then every + // mount of the hull in layout order -- the empty shield mount included -- and CycleAim visits each in turn and wraps. + [Fact] + public void TheCycleCoversEveryLocatedMount() + { + var bay = AddBay(float3(0, 0, 100), gun: true); + var observer = Add(float3(0, 0, 0)); + observer.EntityInfoGathered[bay] = 1f; + var gun = Guns(bay).Single(); + observer.RecordReveal(bay, gun, SignatureChannel.LaunchFlash, gun.Data); + var mounts = bay.HullData.Hardpoints; + Assert.True(bay.Equipment.All(i => i.Hardpoint == null || !ReferenceEquals(i.Hardpoint, mounts[1])), "fixture: the shield mount is empty"); + Target(observer, bay); + + var cycle = observer.AimCycle(bay).ToArray(); + Assert.Equal(4, cycle.Length); + Assert.True(Is(cycle[0], gun, null), "the revealed gun leads"); + Assert.True(Is(cycle[1], null, mounts[0]) && Is(cycle[2], null, mounts[1]) && Is(cycle[3], null, mounts[2]), "then every mount in layout order, the empty one included"); + + for (var i = 0; i < 5; i++) + { + observer.CycleAim(); + var want = cycle[i % 4]; + Assert.True(Is(observer.ResolvedAim, want.Item, want.Mount), $"step {i} of the cycle visits entry {i % 4}, wrapping after the last"); + } + } + + // ---- UnderAHullLieTheCycleIsTheClaimedHulls ---- + + // A Wing claiming to be a Bay and an honest Bay, in the observer's info alike (identified, nothing revealed): the cycle's + // mounts are the claimed hull's own HardpointData in its order -- three, not the Wing's two -- each can be selected, and a + // mount aim prices a shot at the same cells whichever ship it is aimed on. The cycle shows nothing of the lie. + [Fact] + public void UnderAHullLieTheCycleIsTheClaimedHulls() + { + var honest = AddBay(float3(0, 0, 100)); + var (liar, _, _) = AddWing(float3(0, 0, -100)); + var observer = Add(float3(0, 0, 0)); + observer.EntityInfoGathered[honest] = 1f; + observer.EntityInfoGathered[liar] = 1f; + liar.Claim(honest.HullData); + Assert.Equal(2, liar.HullData.Hardpoints.Count); + + var honestMounts = MountsOf(observer.AimCycle(honest)); + var liarMounts = MountsOf(observer.AimCycle(liar)); + Assert.Equal(3, honestMounts.Length); + Assert.True(SameMounts(honestMounts, liarMounts), "the same HardpointData in the same order"); + Assert.True(SameMounts(honest.HullData.Hardpoints.ToArray(), liarMounts), "and they are the claimed hull's"); + Assert.Equal(3, observer.AimCycle(liar).Count()); + + foreach (var (subject, mounts) in new[] { (honest, honestMounts), (liar, liarMounts) }) + { + Target(observer, subject); + foreach (var mount in mounts) Assert.True(observer.TrySelectMount(mount), "each cycle mount can be selected"); + } + + for (var i = 0; i < honestMounts.Length; i++) + { + var onHonest = FireControl.Silhouette(honest, honest.HullData, null, float2(0, 1), 1f, null, honestMounts[i]).AimPoint; + var onLiar = FireControl.Silhouette(liar, liar.HullData, null, float2(0, 1), 1f, null, liarMounts[i]).AimPoint; + Assert.Equal(onHonest.x, onLiar.x, 4); + Assert.Equal(onHonest.y, onLiar.y, 4); + } + var reactorAim = FireControl.Silhouette(liar, liar.HullData, null, float2(0, 1), 1f, null, liarMounts[0]).AimPoint; + Assert.Equal(3.5f, reactorAim.x, 4); + Assert.Equal(3.5f, reactorAim.y, 4); + } + + // ---- TheIdentificationGateReadsOnlyObserverState ---- + + // Whether the cycle holds any mount is the observer's identification of the ship (its info against + // TargetGearInfoThreshold), the same for a liar and an honest ship and with the claim set or not: just below the threshold + // the cycle holds no mount, at it, all of the hull the observer believes in. + [Fact] + public void TheIdentificationGateReadsOnlyObserverState() + { + var honest = AddBay(float3(0, 0, 100)); + var (liar, _, _) = AddWing(float3(0, 0, -100)); + var observer = Add(float3(0, 0, 0)); + + foreach (var claimed in new[] { false, true }) + { + liar.Claim(claimed ? honest.HullData : null); + var believed = claimed ? 3 : 2; + foreach (var (subject, count) in new[] { (honest, 3), (liar, believed) }) + { + observer.EntityInfoGathered[subject] = Identify - .01f; + Assert.Empty(MountsOf(observer.AimCycle(subject))); + observer.EntityInfoGathered[subject] = Identify; + Assert.Equal(count, MountsOf(observer.AimCycle(subject)).Length); + } + } + } + + // ---- AMountSeenDestroyedLeavesTheCycle ---- + + // The reactor is destroyed while the observer is below identification: it sees nothing, and its mount stays in the cycle + // when the identification is later reached but not yet sensed. Sensed at identification, the mount leaves and an aim at + // it resolves to none; it stays out when the info falls back below and recovers (the record is the observer's, not a + // live read), and the other mounts stay. + [Fact] + public void AMountSeenDestroyedLeavesTheCycle() + { + var (bay, observer, reactor) = Identified(Identify - .01f); + var mounts = bay.HullData.Hardpoints; + var sense = SenseOf(observer); + Target(observer, bay); + + reactor.EquippableItem.Durability = 0f; + Assert.True(reactor.EquippableItem.IsDestroyed, "fixture"); + sense(); + + observer.EntityInfoGathered[bay] = Identify; + Assert.True(MountsOf(observer.AimCycle(bay)).Any(m => ReferenceEquals(m, reactor.Hardpoint)), "destroyed below identification: the mount stays"); + Assert.True(observer.TrySelectMount(reactor.Hardpoint), "fixture: aimed at it"); + + sense(); + Assert.True(SameMounts(new[] { mounts[1], mounts[2] }, MountsOf(observer.AimCycle(bay))), "sensed at identification: the reactor mount leaves, the others stay"); + Assert.True(observer.ResolvedAim.Mount == null, "an aim at it resolves to none"); + Assert.False(observer.TrySelectMount(reactor.Hardpoint)); + + observer.EntityInfoGathered[bay] = Identify - .01f; + Assert.Empty(MountsOf(observer.AimCycle(bay))); + observer.EntityInfoGathered[bay] = Identify; + Assert.True(SameMounts(new[] { mounts[1], mounts[2] }, MountsOf(observer.AimCycle(bay))), "it stays out after the identification recovers"); + Assert.False(observer.TrySelectMount(reactor.Hardpoint)); + } + + // ---- ADestroyedClaimedMountIsNotSkippedUnderALie ---- + + // The known gap (follow-up perceived-destruction-under-hull-lie): perceiving a destruction reads the true item, and the + // record keys the true item's mount, so under a hull lie no claimed mount is recorded. The Wing's destroyed guns leave + // all three claimed Bay mounts in the cycle. This pins the gap as it stands, so closing it (aetheria-ew) edits this test. + [Fact] + public void ADestroyedClaimedMountIsNotSkippedUnderALie() + { + var honest = AddBay(float3(0, 0, 100)); + var (liar, gunA, gunB) = AddWing(float3(0, 0, -100)); + var observer = Add(float3(0, 0, 0)); + observer.EntityInfoGathered[liar] = 1f; + liar.Claim(honest.HullData); + + gunA.EquippableItem.Durability = 0f; + gunB.EquippableItem.Durability = 0f; + SenseOf(observer)(); + + Assert.True(SameMounts(honest.HullData.Hardpoints.ToArray(), MountsOf(observer.AimCycle(liar))), "no claimed mount is recorded destroyed"); + } +} diff --git a/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs b/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs index 3415014c3..15fa14568 100644 --- a/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs +++ b/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs @@ -157,7 +157,7 @@ public sealed class TargetingSystemTests : IDisposable // The selection predicate: TrySelectTargetItem accepts only an item of the current target that the observer's // book holds and that is still whole; a Target change nulls the aim point, and destruction drops it on read // with no loop clearing it. Mutation: IsRevealed returns true unconditionally, or ignores the book; the - // Target-change subscription is removed; or ResolvedTargetItem returns TargetItem.Value without re-checking. + // Target-change subscription is removed; or ResolvedTargetItem returns AimedAt.Item without re-checking. [Fact] public void SelectionNeedsReveal() { @@ -166,23 +166,23 @@ public sealed class TargetingSystemTests : IDisposable observer.SetTarget(target); Assert.False(observer.TrySelectTargetItem(weapon)); // nothing seen yet - Assert.Null(observer.TargetItem.Value); + Assert.Null(observer.AimedAt.Item); observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash, weapon.Data); Assert.True(observer.TrySelectTargetItem(weapon)); - Assert.Same(weapon, observer.TargetItem.Value); + Assert.Same(weapon, observer.AimedAt.Item); Assert.Same(weapon, observer.ResolvedTargetItem); // A Target change nulls the aim point. observer.SetTarget(TargetRef.None); - Assert.Null(observer.TargetItem.Value); + Assert.Null(observer.AimedAt.Item); - // Destruction: re-select, then destroy the item without touching TargetItem.Value at all -- the raw field + // Destruction: re-select, then destroy the item without touching AimedAt.Item at all -- the raw field // still holds the item (no loop cleared it), but the read accessor drops it. observer.SetTarget(target); Assert.True(observer.TrySelectTargetItem(weapon)); weapon.EquippableItem.Durability = 0f; - Assert.Same(weapon, observer.TargetItem.Value); + Assert.Same(weapon, observer.AimedAt.Item); Assert.Null(observer.ResolvedTargetItem); }