From 624d5420e956e58e127681984087dd2f03fae63e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 02:27:58 +0200 Subject: [PATCH 1/5] Contacts: hull contact is between ships only; orbitals take no part Step pairs OfType; stations and turrets sit on the orbital layer above the ship plane (ruling aetheria-gravity:orbitals-above-floaters-below). Tests: a ship flies through a station and a turret; two ships still bounce beside an orbital. Not yet compile-verified (verified once at the cut's head). Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Contacts.cs | 9 ++-- tests/Aetheria.Shared.Tests/ContactTests.cs | 60 ++++++++++++++++++--- 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/Assets/Scripts/ServerShared/Contacts.cs b/Assets/Scripts/ServerShared/Contacts.cs index f680faa5a..9eea3691e 100644 --- a/Assets/Scripts/ServerShared/Contacts.cs +++ b/Assets/Scripts/ServerShared/Contacts.cs @@ -7,8 +7,9 @@ using CultMath; using static CultMath.math; using float2 = CultMath.float2; -// A hull touching a hull (aetheria-release cut sim-contact; ruling unity-presents-only-r3): the simulation owns -// contact between entities. Each hull is a disc about its centre of mass that bounds every occupied cell, and +// A ship touching a ship (aetheria-release cut sim-contact; ruling unity-presents-only-r3): the simulation owns +// contact between ships. Orbital entities (stations, turrets) sit on the orbital layer above the ship plane and floaters +// below it, so neither touches a ship (ruling aetheria-gravity:orbitals-above-floaters-below). Each hull is a disc about its centre of mass that bounds every occupied cell, and // two discs that touch while closing along the line between their centres take the normal impulse // j = -(1 + e)(v_rel . n) / (1/m1 + 1/m2) (ruling ship-bounce-restitution-setting): momentum is conserved, the // result is frame-independent, and a pair that has separated is not bounced again. e is @@ -47,7 +48,7 @@ public static class Contacts return far * e.ItemManager.GameplaySettings.SchematicCellSize; } - // For each unordered pair of the zone's entities, in list order: if their discs touch and are closing along + // For each unordered pair of the zone's ships, in list order: if their discs touch and are closing along // the normal, the impulse moves each one's velocity along the normal only (the tangent components are // untouched), and the contact is published. Pairs resolve one after another in list order, each from the // velocities the earlier pairs left, so a body touching two others in one tick depends on that order; the @@ -55,7 +56,7 @@ public static class Contacts // are not hulls in the zone. No positional push-out. public static void Step(Zone zone) { - var entities = zone.Entities.Where(e => e.Active && e.Parent == null).ToArray(); + var entities = zone.Entities.OfType().Where(e => e.Active && e.Parent == null).ToArray(); var radii = entities.Select(Radius).ToArray(); for (var i = 0; i < entities.Length; i++) for (var j = i + 1; j < entities.Length; j++) diff --git a/tests/Aetheria.Shared.Tests/ContactTests.cs b/tests/Aetheria.Shared.Tests/ContactTests.cs index eafb44cc6..47cf05bb2 100644 --- a/tests/Aetheria.Shared.Tests/ContactTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactTests.cs @@ -14,7 +14,8 @@ using int2 = CultMath.int2; // Hull contact in the sim (aetheria-release cut sim-contact; rulings unity-presents-only-r3 and // ship-bounce-restitution-setting): Zone.Update steps Contacts, which applies the restitution impulse along the -// normal of two touching, closing hull discs and publishes the contact. Every test +// normal of two touching, closing ship discs and publishes the contact. Contact is ship on ship: orbital entities +// sit above the ship plane and take no part in it (ruling aetheria-gravity:orbitals-above-floaters-below). Every test // drives Zone.Update, the path the game takes. SchematicCellSize is 2, so a solid 5x5 hull has a radius of // 2.5 * sqrt(2) * 2 = 7.07 and two such hulls ten apart touch. public sealed class ContactTests : IDisposable @@ -70,6 +71,17 @@ public sealed class ContactTests : IDisposable return ship; } + // A station or turret: an orbital entity with no orbit, so it stays where it is put. + public OrbitalEntity AddOrbital(string hull, float3 at, float2 velocity) + { + var hullRef = Items.ItemData.RefOf(Items.ItemData.GetByName(hull)); + var orbital = new OrbitalEntity(Items, Zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = Lot++ }, default, new EntitySettings()); + Zone.Admit(orbital, piloted: false); + orbital.Position = at; + orbital.Velocity = velocity; + return orbital; + } + public void Step() => Zone.Update(.01f); } @@ -339,21 +351,21 @@ public sealed class ContactTests : IDisposable Assert.Empty(lab.Events); } - // A ship against a 1000x heavier body: the heavy body takes (1 + e) m / (m + M) of the ship's closing speed + // A ship against a 1000x heavier ship: the heavy ship takes (1 + e) m / (m + M) of the ship's closing speed // and the ship rebounds with the rest of its momentum. [Fact] - public void AShipAgainstAStationRebounds() + public void AShipAgainstAHeavyHullRebounds() { var lab = Build(.5f); var ship = lab.Add("Solid", float3(0, 0, 0), float2(10, 0)); - var station = lab.Add("Heavy", float3(10, 0, 0), float2(0, 0)); + var heavy = lab.Add("Heavy", float3(10, 0, 0), float2(0, 0)); Assert.Equal(1000f, ship.Mass); - Assert.Equal(1000000f, station.Mass); + Assert.Equal(1000000f, heavy.Mass); lab.Step(); - var n = NormalOf(ship, station); + var n = NormalOf(ship, heavy); var closing = 10f * n.x; - Assert.Equal(1.5f * closing * 1000f / 1001000f, dot(n, station.Velocity), 4); + Assert.Equal(1.5f * closing * 1000f / 1001000f, dot(n, heavy.Velocity), 4); Assert.Equal(closing - 1.5f * closing * 1000000f / 1001000f, dot(n, ship.Velocity), 3); } @@ -371,6 +383,40 @@ public sealed class ContactTests : IDisposable Assert.Equal(-10f, b.Velocity.x, 4); } + // Orbital entities live above the ship plane (ruling orbitals-above-floaters-below): a ship closing on a station + // and on a turret flies through both, and nothing is published. + [Fact] + public void AShipFliesThroughAStationAndATurret() + { + var lab = Build(); + var ship = lab.Add("Solid", float3(0, 0, 0), float2(10, 0)); + var station = lab.AddOrbital("Heavy", float3(10, 0, 0), float2(0, 0)); + var turret = lab.AddOrbital("Solid", float3(-10, 0, 0), float2(10, 0)); + lab.Step(); + Assert.Empty(lab.Events); + Assert.Equal(float2(10, 0), ship.Velocity); + Assert.Equal(float2(0, 0), station.Velocity); + Assert.Equal(float2(10, 0), turret.Velocity); + } + + // An orbital overlapping a colliding pair changes nothing about the pair (ruling ship-bounce-restitution-setting): + // one contact, between the two ships, with the restitution split of the pair alone. + [Fact] + public void TwoShipsStillBounceBesideAnOrbital() + { + var lab = Build(.5f); + var a = lab.Add("Solid", float3(0, 0, 0), float2(10, 0)); + var b = lab.Add("Solid", float3(10, 0, 0), float2(-10, 0)); + lab.AddOrbital("Heavy", float3(5, 0, 0), float2(0, 0)); + lab.Step(); + + var contact = Assert.Single(lab.Events); + Assert.Contains(a, new[] { contact.A, contact.B }); + Assert.Contains(b, new[] { contact.A, contact.B }); + var n = NormalOf(a, b); + Assert.Equal(.5f * 20f * n.x, dot(n, b.Velocity - a.Velocity), 3); + } + // Pairs resolve one after another in list order, so a body touching two others in one tick can leave the pile // differently when the list is reversed (order independence is not claimed); what the order does not change is // the momentum of the pile and the two contacts it publishes. From be7f2b07871b8efd8e075cddc1f5273bc1a7fc08 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 02:35:07 +0200 Subject: [PATCH 2/5] Layers: one owner puts orbitals above the ship plane and floaters below; per-hull GridOffset is deleted GameplaySettings carries ShipLayerHeight 3, OrbitalLayerHeight 15, FloaterLayerHeight 0 (authored in Settings.asset). Entity.LayerHeight (Ship, OrbitalEntity) is the one offset RideHeight adds; DrawAhead.FloaterHeight is the floater height ItemPickup and MineInstance draw at. HullData.GridOffset (Key 26) and the pickup/mine/prefab GridOffset fields are gone with every reader. Fixtures (DrawnPose, FireControl*, ImpactFeedback) take heights from the layer owner; LayerTests pin each kind's layer. FloatingItemsDrawnSmoke follows. ItemPickup/MineInstance.Bind take the settings from ZoneRenderer. Builds as one commit with its tests; not compile-verified until the cut's head run. Co-Authored-By: Claude Opus 5.5 --- Assets/Content/Prefabs/Mine.prefab | 1 - .../Prefabs/RPG/Pickups/Dodecahedron.prefab | 1 - Assets/Prefabs/RPG/Pickups/Hexahedron.prefab | 1 - Assets/Prefabs/RPG/Pickups/Icosahedron.prefab | 1 - Assets/Prefabs/RPG/Pickups/Octahedron.prefab | 1 - Assets/Prefabs/RPG/Pickups/Tetrahedron.prefab | 1 - Assets/Resources/Settings.asset | 4 +- .../Scripts/Editor/FloatingItemsDrawnSmoke.cs | 4 +- Assets/Scripts/Gameplay/ItemPickup.cs | 7 +- Assets/Scripts/Gameplay/MineInstance.cs | 7 +- Assets/Scripts/ServerShared/DrawAhead.cs | 3 + Assets/Scripts/ServerShared/Entity.cs | 10 +- Assets/Scripts/ServerShared/ItemData.cs | 3 +- Assets/Scripts/ServerShared/OrbitalEntity.cs | 2 + Assets/Scripts/ServerShared/Settings.cs | 7 + Assets/Scripts/ServerShared/Ship.cs | 2 + Assets/Scripts/Zone Display/ZoneRenderer.cs | 4 +- tests/Aetheria.Shared.Tests/ContactTests.cs | 5 +- tests/Aetheria.Shared.Tests/DrawnPoseTests.cs | 14 +- .../FireControlPerWeaponTests.cs | 29 ++-- .../Aetheria.Shared.Tests/FireControlTests.cs | 11 +- .../ImpactFeedbackTests.cs | 12 +- tests/Aetheria.Shared.Tests/LayerTests.cs | 126 ++++++++++++++++++ 23 files changed, 205 insertions(+), 51 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/LayerTests.cs diff --git a/Assets/Content/Prefabs/Mine.prefab b/Assets/Content/Prefabs/Mine.prefab index dbc1e0915..912986ee7 100644 --- a/Assets/Content/Prefabs/Mine.prefab +++ b/Assets/Content/Prefabs/Mine.prefab @@ -146,6 +146,5 @@ MonoBehaviour: ActiveEmission: 100 ArmedEmission: 1000 RotationSpeed: 1 - GridOffset: 0 HitEffect: {fileID: 1121192042590650, guid: e1b08032bec16ba4eb1ae23cbd572664, type: 3} HitEffectScale: 10 diff --git a/Assets/Prefabs/RPG/Pickups/Dodecahedron.prefab b/Assets/Prefabs/RPG/Pickups/Dodecahedron.prefab index 5d4f837d1..7a65a0864 100644 --- a/Assets/Prefabs/RPG/Pickups/Dodecahedron.prefab +++ b/Assets/Prefabs/RPG/Pickups/Dodecahedron.prefab @@ -189,7 +189,6 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: RotationSpeed: 1 - GridOffset: 0 LabelFadeDuration: 0.5 LabelPersistDuration: 3 LabelDisplayAngle: 15 diff --git a/Assets/Prefabs/RPG/Pickups/Hexahedron.prefab b/Assets/Prefabs/RPG/Pickups/Hexahedron.prefab index 91f1c2ad3..f9a477cda 100644 --- a/Assets/Prefabs/RPG/Pickups/Hexahedron.prefab +++ b/Assets/Prefabs/RPG/Pickups/Hexahedron.prefab @@ -162,7 +162,6 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: RotationSpeed: 1 - GridOffset: 0 LabelFadeDuration: 0.5 LabelPersistDuration: 3 LabelDisplayAngle: 15 diff --git a/Assets/Prefabs/RPG/Pickups/Icosahedron.prefab b/Assets/Prefabs/RPG/Pickups/Icosahedron.prefab index 95973ba56..4252e74cc 100644 --- a/Assets/Prefabs/RPG/Pickups/Icosahedron.prefab +++ b/Assets/Prefabs/RPG/Pickups/Icosahedron.prefab @@ -162,7 +162,6 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: RotationSpeed: 1 - GridOffset: 0 LabelFadeDuration: 0.5 LabelPersistDuration: 3 LabelDisplayAngle: 15 diff --git a/Assets/Prefabs/RPG/Pickups/Octahedron.prefab b/Assets/Prefabs/RPG/Pickups/Octahedron.prefab index 5957a7295..099083195 100644 --- a/Assets/Prefabs/RPG/Pickups/Octahedron.prefab +++ b/Assets/Prefabs/RPG/Pickups/Octahedron.prefab @@ -162,7 +162,6 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: RotationSpeed: 1 - GridOffset: 0 LabelFadeDuration: 0.5 LabelPersistDuration: 3 LabelDisplayAngle: 15 diff --git a/Assets/Prefabs/RPG/Pickups/Tetrahedron.prefab b/Assets/Prefabs/RPG/Pickups/Tetrahedron.prefab index 8688b6a3b..6dad19967 100644 --- a/Assets/Prefabs/RPG/Pickups/Tetrahedron.prefab +++ b/Assets/Prefabs/RPG/Pickups/Tetrahedron.prefab @@ -162,7 +162,6 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: RotationSpeed: 1 - GridOffset: 0 LabelFadeDuration: 0.5 LabelPersistDuration: 3 LabelDisplayAngle: 15 diff --git a/Assets/Resources/Settings.asset b/Assets/Resources/Settings.asset index b007e4a33..4b17fe993 100644 --- a/Assets/Resources/Settings.asset +++ b/Assets/Resources/Settings.asset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f5fda2fa736e02d879a317152738e0a4c44c10b356a6dbd41cf31d2d7e7e6fd8 -size 12571 +oid sha256:248373994a9d0caf17b8dcddbedb7b8c1d36001c31ae0f1e695385e6589734e6 +size 12647 diff --git a/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs b/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs index 54e2b39fb..eb4a75558 100644 --- a/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs +++ b/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs @@ -90,6 +90,8 @@ public static class FloatingItemsDrawnSmoke { var settings = AssetDatabase.LoadAssetAtPath("Assets/Resources/Settings.asset")?.GameplaySettings ?? throw new InvalidOperationException("No Settings.asset."); + // A floater rides its own layer: a nonzero one (never saved back to the asset), so a presenter that drops it fails. + settings.FloaterLayerHeight = -1.5f; // The wiring: the scene's ZoneRenderer names an ItemPickup prefab in each of the four fields, and none of those // prefabs keeps a script that no longer exists. @@ -159,7 +161,7 @@ public static class FloatingItemsDrawnSmoke { var lead = ActionGameManager.Clock.Lead; var at = DrawAhead.Position(floating.Body, lead); - var want = new Vector3(at.x, DrawAhead.Height(zone, at, lead), at.y); + var want = new Vector3(at.x, DrawAhead.FloaterHeight(zone, settings, at, lead), at.y); Require(Vector3.Distance(pickup.transform.position, want) < 1e-3f, $"{what}: the pickup is at {pickup.transform.position}, the body at {want}."); } diff --git a/Assets/Scripts/Gameplay/ItemPickup.cs b/Assets/Scripts/Gameplay/ItemPickup.cs index 4f78dc848..1378d6e28 100644 --- a/Assets/Scripts/Gameplay/ItemPickup.cs +++ b/Assets/Scripts/Gameplay/ItemPickup.cs @@ -12,7 +12,6 @@ using Random = UnityEngine.Random; public class ItemPickup : MonoBehaviour { public float RotationSpeed = 1f; - public float GridOffset; public float LabelFadeDuration = .5f; public float LabelPersistDuration = 3; public float LabelDisplayAngle = 15; @@ -27,11 +26,13 @@ public class ItemPickup : MonoBehaviour private float _displayTime; private float _timeOffset; private Zone _zone; + private GameplaySettings _settings; private FloatingItem _floating; - public void Bind(Zone zone, FloatingItem floating) + public void Bind(Zone zone, FloatingItem floating, GameplaySettings settings) { _zone = zone; + _settings = settings; _floating = floating; Item = floating.Item; _timeOffset = Random.value * 100; @@ -42,7 +43,7 @@ public class ItemPickup : MonoBehaviour { var lead = ActionGameManager.Clock.Lead; var p = DrawAhead.Position(_floating.Body, lead); - transform.position = new Vector3(p.x, DrawAhead.Height(_zone, p, lead) + GridOffset, p.y); + transform.position = new Vector3(p.x, DrawAhead.FloaterHeight(_zone, _settings, p, lead), p.y); } private void Update() diff --git a/Assets/Scripts/Gameplay/MineInstance.cs b/Assets/Scripts/Gameplay/MineInstance.cs index c09de1fdf..006f7d631 100644 --- a/Assets/Scripts/Gameplay/MineInstance.cs +++ b/Assets/Scripts/Gameplay/MineInstance.cs @@ -17,21 +17,22 @@ public class MineInstance : MonoBehaviour public float ActiveEmission = 100f; public float ArmedEmission = 1000f; public float RotationSpeed = 1f; - public float GridOffset; // The blast's particles; the effect prefab destroys itself when its particle system stops. public GameObject HitEffect; public float HitEffectScale = 10f; private Zone _zone; + private GameplaySettings _settings; private Mine _mine; private Material _material; private float _timeOffset; private float _pulse; private float _emission; - public void Bind(Zone zone, Mine mine) + public void Bind(Zone zone, Mine mine, GameplaySettings settings) { _zone = zone; + _settings = settings; _mine = mine; _material = MeshRenderer.materials[EmissionSubmesh]; _timeOffset = Random.value * 100; @@ -57,7 +58,7 @@ public class MineInstance : MonoBehaviour { var lead = ActionGameManager.Clock.Lead; var p = DrawAhead.Position(_mine.Body, lead); - transform.position = new Vector3(p.x, DrawAhead.Height(_zone, p, lead) + GridOffset, p.y); + transform.position = new Vector3(p.x, DrawAhead.FloaterHeight(_zone, _settings, p, lead), p.y); } // The mine left Zone.Mines, which only its blast does: show it where it was drawn last. diff --git a/Assets/Scripts/ServerShared/DrawAhead.cs b/Assets/Scripts/ServerShared/DrawAhead.cs index 92c5c8d9f..49ca0a3d0 100644 --- a/Assets/Scripts/ServerShared/DrawAhead.cs +++ b/Assets/Scripts/ServerShared/DrawAhead.cs @@ -26,6 +26,9 @@ public static class DrawAhead // The field's height at a point at the drawn time, its wells where their rails put them at T. public static float Height(Zone zone, float2 planar, float lead) => zone.GetHeight(planar, Time(zone, lead)); + // The drawn height of a floater (loot, mines): the field at the drawn time plus the floater layer. + public static float FloaterHeight(Zone zone, GameplaySettings settings, float2 planar, float lead) => Height(zone, planar, lead) + settings.FloaterLayerHeight; + // A ship in a wormhole animation has its pose scripted by the step, not integrated from Velocity and TurnRate, // so it is drawn where the step put it: extrapolating a scripted pose draws a streak that resets every step. // A child is drawn where its parent is drawn, as the step copies the parent's pose onto it. diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index edfe29a67..4d9470963 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -1974,9 +1974,13 @@ public abstract class Entity Manoeuvre.Observe(Acceleration, delta, ItemManager.GameplaySettings.SolutionWindow); } - // Where an unparented entity sits on the field at a planar point: the zone's height there plus the hull's grid offset. - // The step rides at its own time and xz, the presenter at the drawn time and its drawn xz (DrawAhead). - public float RideHeight(float2 planar, double time) => Zone.GetHeight(planar, time) + (ItemManager.GetData(Hull) as HullData).GridOffset; + // The height above the field of the layer this kind of entity rides on (GameplaySettings): ships the ship plane, + // orbital entities the orbital layer above it. + public abstract float LayerHeight { get; } + + // Where an unparented entity sits on the field at a planar point: the zone's height there plus the layer this kind of + // entity rides on. The step rides at its own time and xz, the presenter at the drawn time and its drawn xz (DrawAhead). + public float RideHeight(float2 planar, double time) => Zone.GetHeight(planar, time) + LayerHeight; public float RideHeight(float2 planar) => RideHeight(planar, Zone.ExactTime); diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 9b9c057e7..8f3b8c4ea 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -519,8 +519,7 @@ public class HullData : EquippableItemData [Inspectable, JsonProperty("hullType"), Key(25)] public HullType HullType; - [Inspectable, JsonProperty("gridOffset"), Key(26)] - public float GridOffset; + // Key(26) retired 2026-10-16 (per-hull GridOffset; the layer is the kind's, GameplaySettings). Do not reuse. [Inspectable, JsonProperty("armor"), Key(27)] public float Armor; diff --git a/Assets/Scripts/ServerShared/OrbitalEntity.cs b/Assets/Scripts/ServerShared/OrbitalEntity.cs index 93f055804..a2c511ba7 100644 --- a/Assets/Scripts/ServerShared/OrbitalEntity.cs +++ b/Assets/Scripts/ServerShared/OrbitalEntity.cs @@ -23,6 +23,8 @@ public class OrbitalEntity : Entity OrbitData = orbit; } + public override float LayerHeight => ItemManager.GameplaySettings.OrbitalLayerHeight; + public override void Update(float delta) { if (OrbitData.IsSet()) diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index f09a6b187..cda5e1623 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -278,6 +278,13 @@ public class GameplaySettings // normal is this times the speed at which it closed. 0 is perfectly inelastic, 1 perfectly elastic. public float ContactRestitution = .5f; + // Heights above the field of the ship plane, the orbital layer (stations, turrets: every OrbitalEntity) and the floater + // layer (loot, mines): orbitals above ships and floaters below them, so no ship ever touches either (ruling + // aetheria-gravity:orbitals-above-floaters-below). + public float ShipLayerHeight = 3f; + public float OrbitalLayerHeight = 15f; + public float FloaterLayerHeight = 0f; + // The floor an AI's own predicted hit probability (FireControl.HitProbability) must clear before it counts // a firing solution worth taking (Combat.cs, TurretController.cs). Player fire is gated on arc alone // (Weapon.ArcAllowsFire, Q2) -- this threshold is an AI fire-discipline heuristic, not part of the roll. diff --git a/Assets/Scripts/ServerShared/Ship.cs b/Assets/Scripts/ServerShared/Ship.cs index 560afce17..4d621e2f9 100644 --- a/Assets/Scripts/ServerShared/Ship.cs +++ b/Assets/Scripts/ServerShared/Ship.cs @@ -16,6 +16,8 @@ using quaternion = CultMath.quaternion; [MessagePackObject] public class Ship : Entity { + public override float LayerHeight => ItemManager.GameplaySettings.ShipLayerHeight; + // [Key("bindings")] public Dictionary Bindings = new Dictionary(); //[IgnoreMember] public int HullHardpointCount; diff --git a/Assets/Scripts/Zone Display/ZoneRenderer.cs b/Assets/Scripts/Zone Display/ZoneRenderer.cs index e92c78c27..6bc9b45e3 100644 --- a/Assets/Scripts/Zone Display/ZoneRenderer.cs +++ b/Assets/Scripts/Zone Display/ZoneRenderer.cs @@ -282,7 +282,7 @@ public class ZoneRenderer : MonoBehaviour private void LoadMine(Zone zone, Mine mine) { var instance = Instantiate(MinePrefab); - instance.Bind(zone, mine); + instance.Bind(zone, mine, ItemManager.GameplaySettings); _mines.Add(mine, instance); } @@ -297,7 +297,7 @@ public class ZoneRenderer : MonoBehaviour _ => throw new NotImplementedException() }; var pickup = Instantiate(prefab, ZoneRoot); - pickup.Bind(zone, floating); + pickup.Bind(zone, floating, ItemManager.GameplaySettings); pickup.ScanLabel.text = ItemManager.Title(floating.Item); if (floating.Item is CraftedItemInstance craftedItemInstance) { diff --git a/tests/Aetheria.Shared.Tests/ContactTests.cs b/tests/Aetheria.Shared.Tests/ContactTests.cs index 47cf05bb2..2efc5431c 100644 --- a/tests/Aetheria.Shared.Tests/ContactTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactTests.cs @@ -391,12 +391,13 @@ public sealed class ContactTests : IDisposable var lab = Build(); var ship = lab.Add("Solid", float3(0, 0, 0), float2(10, 0)); var station = lab.AddOrbital("Heavy", float3(10, 0, 0), float2(0, 0)); - var turret = lab.AddOrbital("Solid", float3(-10, 0, 0), float2(10, 0)); + // Behind the ship and faster than it, so it closes on the ship as the station does. + var turret = lab.AddOrbital("Solid", float3(-10, 0, 0), float2(30, 0)); lab.Step(); Assert.Empty(lab.Events); Assert.Equal(float2(10, 0), ship.Velocity); Assert.Equal(float2(0, 0), station.Velocity); - Assert.Equal(float2(10, 0), turret.Velocity); + Assert.Equal(float2(30, 0), turret.Velocity); } // An orbital overlapping a colliding pair changes nothing about the pair (ruling ship-bounce-restitution-setting): diff --git a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs index e30f2869b..be622e298 100644 --- a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs +++ b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs @@ -26,7 +26,8 @@ public sealed class DrawnPoseTests : IDisposable private const float PlanetDistance = 1000f, PlanetPhase = .3f; private const float MoonDistance = 60f, MoonPhase = .1f; private const float StationDistance = 3f, StationPhase = .4f; - private const float GridOffset = 2.5f; + private const float ShipLayerHeight = 2.5f; + private const float OrbitalLayerHeight = 7.5f; // The sun's rail is parentless and off-centre, the shape ZoneGenerator gives a cluster root (FixedPosition = cluster centre). private static readonly float2 SunCentre = new float2(700f, -300f); @@ -73,7 +74,7 @@ public sealed class DrawnPoseTests : IDisposable using (var build = AetheriaStores.Open(catalog, catalogWritable: true)) { build.Upsert(new VerseGrammar { Revision = 1 }); - build.Upsert(new HullData { Name = "TestHull", HullType = HullType.Ship, Shape = shape, Durability = 1, Mass = 1000, GridOffset = GridOffset }); + build.Upsert(new HullData { Name = "TestHull", HullType = HullType.Ship, Shape = shape, Durability = 1, Mass = 1000 }); build.FlushAsync().Wait(); } @@ -85,7 +86,8 @@ public sealed class DrawnPoseTests : IDisposable { DefaultEntitySettings = new EntitySettings(), Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, - QualityPriceModifier = new ExponentialLerp() + QualityPriceModifier = new ExponentialLerp(), + ShipLayerHeight = ShipLayerHeight, OrbitalLayerHeight = OrbitalLayerHeight }, _ => { }); CultRecordKey Orbit(CultRecordKey parent, float distance, float phase, float2 fixedPosition = default) @@ -270,9 +272,9 @@ public sealed class DrawnPoseTests : IDisposable // A ship that stands still 40 u from the planet rides the field of the drawn well, plus its hull's offset. var ship = new Ship(w.Items, w.Zone, w.Hull, new EntitySettings()) { Position = float3(PlanetAt(w.Zone.ExactTime).x + 40f, 0, PlanetAt(w.Zone.ExactTime).y) }; var drawnShip = DrawAhead.Position(ship, lead); - Assert.Equal(FieldAt(w, drawnShip.xz, time) + GridOffset, drawnShip.y, 3); + Assert.Equal(FieldAt(w, drawnShip.xz, time) + ShipLayerHeight, drawnShip.y, 3); Assert.Equal(ship.RideHeight(drawnShip.xz, time), drawnShip.y, 4); - Assert.Equal(w.Zone.GetHeight(ship.Position.xz) + GridOffset, ship.RideHeight(ship.Position.xz), 4); + Assert.Equal(w.Zone.GetHeight(ship.Position.xz) + ShipLayerHeight, ship.RideHeight(ship.Position.xz), 4); } [Fact] @@ -292,7 +294,7 @@ public sealed class DrawnPoseTests : IDisposable AssertNear(StationAt(w.Zone.ExactTime + lead), drawn.xz, 2e-3f, "the station on its rail at the drawn time"); AssertNear(DrawAhead.Orbit(w.Zone, w.StationOrbit, lead), drawn.xz, 1e-6f, "the same point DrawAhead.Orbit gives"); Assert.True(length(station.Position.xz + station.Velocity * lead - drawn.xz) > .01f, "a tight rail curves away from its straight extrapolation"); - Assert.Equal(w.Zone.GetHeight(drawn.xz, w.Zone.ExactTime + lead) + GridOffset, drawn.y, 4); + Assert.Equal(w.Zone.GetHeight(drawn.xz, w.Zone.ExactTime + lead) + OrbitalLayerHeight, drawn.y, 4); } // The generators make orbital entities with no orbit set (Loadout, LoadoutGenerator): a rail-less one is an ordinary diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 4e9a23268..1d8f9f6be 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -35,8 +35,9 @@ public sealed class FireControlPerWeaponTests : IDisposable Directory.Delete(_root, true); } - private static GameplaySettings TestSettings(float minHit) => new GameplaySettings + private static GameplaySettings TestSettings(float minHit, float orbitalLayerHeight) => new GameplaySettings { + OrbitalLayerHeight = orbitalLayerHeight, DefaultEntitySettings = new EntitySettings(), Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, QualityPriceModifier = new ExponentialLerp(), @@ -71,7 +72,7 @@ public sealed class FireControlPerWeaponTests : IDisposable { public Zone Zone; public Ship Shooter; - public Ship Target; + public Entity Target; public List Guns = new List(); public Gun Gun => Guns[0]; public Gun Decoy; @@ -84,12 +85,12 @@ public sealed class FireControlPerWeaponTests : IDisposable (ItemRotation Rotation, float Arc)[] mounts = null, bool beam = false, WeaponFuse? fuse = null, float blast = 0f, float range = 100f, float targetRange = 60f, float accuracy = 1f, float minHit = .2f, bool lockWeapon = false, bool turret = false, float energy = 0f, float heat = 0f, float visibility = 0f, int magazine = 0, bool singleAmmoBurst = false, bool decoy = false, bool charged = false, bool auto = false, - float effective = float.PositiveInfinity, float damageAtMax = 0f, float velocity = 0f, float targetGridOffset = 0f) + float effective = float.PositiveInfinity, float damageAtMax = 0f, float velocity = 0f, float targetLayerHeight = 0f) { mounts ??= new[] { (ItemRotation.None, 0f) }; var hullData = new HullData { - Name = "Hull", HullType = HullType.Ship, Shape = SolidShape(5, 4), Durability = 1000000, Mass = 1000, Armor = 0, GridOffset = targetGridOffset, + Name = "Hull", HullType = HullType.Ship, Shape = SolidShape(5, 4), Durability = 1000000, Mass = 1000, Armor = 0, Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } }; var shooterHullData = new HullData @@ -192,7 +193,7 @@ public sealed class FireControlPerWeaponTests : IDisposable var ledger = new ProvenanceLedger(); for (var i = 1; i <= 100; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, TestSettings(minHit), _ => { }); + var items = new ItemManager(cache, ledger, TestSettings(minHit, targetLayerHeight), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "PerWeapon", Owner = null }, null); EquippableItem Make(string name, int lot, float durability) => new EquippableItem @@ -235,7 +236,12 @@ public sealed class FireControlPerWeaponTests : IDisposable Assert.True(shooter.TryEquip(t, upos)); } - var target = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); + // A target layer height rides the target on the orbital layer at that height (the shooter is a ship, on the ship layer), + // so the two sit at different heights. + var targetItem = new EquippableItem { Data = hullRef, Durability = 1000000, Lot = lot++ }; + Entity target = targetLayerHeight != 0f + ? new OrbitalEntity(items, zone, targetItem, default, new EntitySettings()) + : new Ship(items, zone, targetItem, new EntitySettings()); Assert.True(target.TryEquip(Make("Gun", lot++, 1), new int2(0, 0))); zone.Admit(shooter, piloted: false); @@ -308,13 +314,14 @@ public sealed class FireControlPerWeaponTests : IDisposable Assert.True(r.Gun.Weapon.Firing, "the turret must fire a fused weapon at a designated target out of arc"); } - // The AI aims at the height a target rides at (Entity.RideHeight: field plus the hull's GridOffset), not at the - // plane. A target hull raised 40 u, a shot fast enough to be predicted: the turret's and Combat's aim point at the - // target's stepped pose. Kills: either aim site spelling its own offset (dropped, added to, or scaled). + // The AI aims at the height a target rides at (Entity.RideHeight: field plus the layer the target's kind rides on), + // not at the plane and not at the shooter's own layer. A target on a layer 40 u up against a shooter on the ship + // layer, a shot fast enough to be predicted: the turret's and Combat's aim point at the target's stepped pose. + // Kills: either aim site spelling its own offset (dropped, added to, scaled, or the shooter's layer). [Fact] public void ATurretAimsAtTheHeightATargetRidesAt() { - var r = Build(turret: true, velocity: 500f, targetGridOffset: 40f); + var r = Build(turret: true, velocity: 500f, targetLayerHeight: 40f); r.Zone.Update(1f); AssertAimsAtTarget(r); @@ -323,7 +330,7 @@ public sealed class FireControlPerWeaponTests : IDisposable [Fact] public void CombatAimsAtTheHeightATargetRidesAt() { - var r = Build(velocity: 500f, targetGridOffset: 40f); + var r = Build(velocity: 500f, targetLayerHeight: 40f); r.Zone.Agents.Add(new Minion(r.Shooter)); r.Zone.Update(1f); r.Zone.Update(1f); diff --git a/tests/Aetheria.Shared.Tests/FireControlTests.cs b/tests/Aetheria.Shared.Tests/FireControlTests.cs index 95b85df46..2d9a91d6b 100644 --- a/tests/Aetheria.Shared.Tests/FireControlTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlTests.cs @@ -29,8 +29,9 @@ public sealed class FireControlTests : IDisposable Directory.Delete(_root, true); } - private static GameplaySettings TestSettings() => new GameplaySettings + private static GameplaySettings TestSettings(float shipLayerHeight = 3f) => new GameplaySettings { + ShipLayerHeight = shipLayerHeight, DefaultEntitySettings = new EntitySettings(), Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, QualityPriceModifier = new ExponentialLerp(), @@ -45,13 +46,13 @@ public sealed class FireControlTests : IDisposable // `well` digs the zone one well at the origin (WellFixture), so heights differ across the zone. private (ItemManager items, Ship ship, EquippedItem[] weapons) BuildShipWithHardpoints( - bool well, (ItemRotation rotation, float firingArc)[] hardpoints, float gridOffset = 0f) + bool well, (ItemRotation rotation, float firingArc)[] hardpoints, float shipLayerHeight = 0f) { var hullShape = new Shape(Math.Max(hardpoints.Length, 3), 3); foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; // Mass defaults to 0 (ItemData.cs:285), which zeroes ThermalMass (Entity.cs MapEntity) and NaNs // UpdateTemperature -- author a real mass so the entity's own temperature stays a real number. - var hullData = new HullData { Name = "TestHull", HullType = HullType.Ship, Shape = hullShape, Durability = 1, Mass = 1000, GridOffset = gridOffset }; + var hullData = new HullData { Name = "TestHull", HullType = HullType.Ship, Shape = hullShape, Durability = 1, Mass = 1000 }; for (var i = 0; i < hardpoints.Length; i++) hullData.Hardpoints.Add(new HardpointData { @@ -87,7 +88,7 @@ public sealed class FireControlTests : IDisposable ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; // hull for (var i = 0; i < hardpoints.Length; i++) ledger.Lots[2 + i] = new Lot { Origin = new Attributed(), Quality = 1 }; // guns - var items = new ItemManager(_openCache, ledger, TestSettings(), _ => { }); + var items = new ItemManager(_openCache, ledger, TestSettings(shipLayerHeight), _ => { }); var planets = new PlanetSettings(); var pack = new ZonePack(); if (well) WellFixture.Dig(_openCache, pack, planets, 300f, 100f); @@ -386,7 +387,7 @@ public sealed class FireControlTests : IDisposable [Fact] public void ADrawnShipRidesTheFieldAtItsDrawnPosition() { - var (items, ship, _) = BuildShipWithHardpoints(true, new[] { (ItemRotation.None, 0f) }, gridOffset: 2.5f); + var (items, ship, _) = BuildShipWithHardpoints(true, new[] { (ItemRotation.None, 0f) }, shipLayerHeight: 2.5f); var zone = ship.Zone; ship.Position = float3(200, 0, 0); zone.Update(1f); diff --git a/tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs b/tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs index d735b68a8..f95300ae0 100644 --- a/tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs +++ b/tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs @@ -354,8 +354,9 @@ public sealed partial class FireControlCut124Tests { foreach (var shield in new[] { false, true }) { - var e = Build(TestSettings(), SolidShape(5, 4), equipShield: shield, shieldCapacity: 100000f); - e.HullData.GridOffset = 5f; + var raised = TestSettings(); + raised.ShipLayerHeight = 5f; + var e = Build(raised, SolidShape(5, 4), equipShield: shield, shieldCapacity: 100000f); for (var i = 0; i < 30; i++) e.Zone.Update(.1f); Assert.Equal(5f, e.Target.Position.y, 1e-4); var subs = new List(); @@ -434,9 +435,10 @@ public sealed partial class FireControlCut124Tests [Fact] public void TheImpactHeightIsTheTargetsHeightAndCarriedKeepsRelativeHeight() { - // Production y is the zone height plus the hull's GridOffset, so a raised hull is reachable headless. - var e = Build(TestSettings(), SolidShape(21, 3), velocity: 0, damage: 100f, custom: new[] { ("Marker", new Shape(), new int2(2, 1), 1f) }); - e.HullData.GridOffset = 5f; + // Production y is the zone height plus the ship layer, so a raised ship plane is reachable headless. + var raised = TestSettings(); + raised.ShipLayerHeight = 5f; + var e = Build(raised, SolidShape(21, 3), velocity: 0, damage: 100f, custom: new[] { ("Marker", new Shape(), new int2(2, 1), 1f) }); e.Zone.Update(.01f); Assert.Equal(5f, e.Target.Position.y, 1e-4); var subs = new List(); diff --git a/tests/Aetheria.Shared.Tests/LayerTests.cs b/tests/Aetheria.Shared.Tests/LayerTests.cs new file mode 100644 index 000000000..171a170a1 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/LayerTests.cs @@ -0,0 +1,126 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using CultMath; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using Xunit; +using float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; + +// Layers (aetheria-gravity cut orbital-layering; ruling orbitals-above-floaters-below): GameplaySettings' three +// heights decide where each kind of thing sits above the field. A ship rides the ship plane, every orbital entity +// (station, turret) the orbital layer above it, a floater (loot, mine) the floater layer below. No hull carries a +// height of its own. +public sealed class LayerTests : IDisposable +{ + private const float ShipHeight = 2.5f, OrbitalHeight = 9f, FloaterHeight = -1.25f; + + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-layers-" + Guid.NewGuid().ToString("N")); + private readonly CultCache _cache; + + public LayerTests() + { + Directory.CreateDirectory(_root); + _cache = AetheriaStores.Open(Path.Combine(_root, "layers.cc"), catalogWritable: true); + } + + public void Dispose() + { + _cache.Dispose(); + Directory.Delete(_root, true); + } + + private static GameplaySettings Settings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + ShipLayerHeight = ShipHeight, OrbitalLayerHeight = OrbitalHeight, FloaterLayerHeight = FloaterHeight + }; + + private (ItemManager Items, Zone Zone) Build() + { + // Hull sizes and masses are spread so no kind coincides with a size or a mass: a layer chosen by either fails. + Shape Solid(int side) + { + var shape = new Shape(side, side); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + return shape; + } + _cache.Upsert(new VerseGrammar { Revision = 1 }); + _cache.Upsert(new HullData { Name = "Ship", HullType = HullType.Ship, Shape = Solid(5), Durability = 1, Mass = 1000 }); + _cache.Upsert(new HullData { Name = "Station", HullType = HullType.Station, Shape = Solid(3), Durability = 1, Mass = 1000000 }); + _cache.Upsert(new HullData { Name = "Turret", HullType = HullType.Turret, Shape = Solid(7), Durability = 1, Mass = 50 }); + _cache.FlushAsync().Wait(); + var items = new ItemManager(_cache, new ProvenanceLedger(), Settings(), _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack { Radius = 0 }, new GalaxyZone { Name = "Layers", Owner = null }, null); + return (items, zone); + } + + private static EquippableItem Hull(ItemManager items, string name, int lot) => new EquippableItem + { + Data = items.ItemData.RefOf(items.ItemData.GetByName(name)), Durability = 1, Lot = lot + }; + + [Fact] + public void EachKindRidesItsLayer() + { + var (items, zone) = Build(); + var ship = new Ship(items, zone, Hull(items, "Ship", 1), new EntitySettings()); + var station = new OrbitalEntity(items, zone, Hull(items, "Station", 2), default, new EntitySettings()); + var turret = new OrbitalEntity(items, zone, Hull(items, "Turret", 3), default, new EntitySettings()); + foreach (var e in new Entity[] { ship, station, turret }) zone.Admit(e, piloted: false); + ship.Position = float3(40, 0, 10); + station.Position = float3(-30, 0, 20); + turret.Position = float3(5, 0, -60); + + zone.Update(.1f); + + Assert.Equal(zone.GetHeight(ship.Position.xz) + ShipHeight, ship.Position.y); + Assert.Equal(zone.GetHeight(station.Position.xz) + OrbitalHeight, station.Position.y); + Assert.Equal(zone.GetHeight(turret.Position.xz) + OrbitalHeight, turret.Position.y); + const float lead = .05f; + foreach (var (e, layer) in new (Entity, float)[] { (ship, ShipHeight), (station, OrbitalHeight), (turret, OrbitalHeight) }) + { + var drawn = DrawAhead.Position(e, lead); + Assert.Equal(zone.GetHeight(drawn.xz, DrawAhead.Time(zone, lead)) + layer, drawn.y); + } + } + + [Fact] + public void OrbitalsAboveShipsAboveFloaters() + { + var authored = AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read("GameplaySettings"); + Assert.True(authored.OrbitalLayerHeight > authored.ShipLayerHeight); + Assert.True(authored.ShipLayerHeight > authored.FloaterLayerHeight); + } + + [Fact] + public void AFloaterSitsOnTheFloaterLayer() + { + var (items, zone) = Build(); + var settings = items.GameplaySettings; + foreach (var at in new[] { float2(0, 0), float2(37, -12) }) + foreach (var lead in new[] { 0f, .05f }) + Assert.Equal(DrawAhead.Height(zone, at, lead) + FloaterHeight, DrawAhead.FloaterHeight(zone, settings, at, lead)); + } + + [Fact] + public void HullKey26IsRetired() + { + var keys = new System.Collections.Generic.HashSet(); + for (var t = typeof(HullData); t != null; t = t.BaseType) + foreach (var m in t.GetMembers(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + foreach (var k in m.GetCustomAttributes(typeof(MessagePack.KeyAttribute), false).Cast()) + if (k.IntKey.HasValue) keys.Add(k.IntKey.Value); + Assert.NotEmpty(keys); + Assert.DoesNotContain(26, keys); + + using var shipped = new CultCache(TestCatalog.Registry()); + shipped.AddBackingStore(new SingleFileMessagePackBackingStore(TestCatalog.Repo, true), AetheriaStores.CatalogTypes); + Assert.NotEmpty(shipped.GetAll()); + } +} From a0a85920cd9e3486ae223ff6a3ee2a1228d78f3d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 03:17:42 +0200 Subject: [PATCH 3/5] LayerTests: using static CultMath.math (fixes the compile error in be7f2b07; be7f2b07 does not build) Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/LayerTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Aetheria.Shared.Tests/LayerTests.cs b/tests/Aetheria.Shared.Tests/LayerTests.cs index 171a170a1..f6a61ae97 100644 --- a/tests/Aetheria.Shared.Tests/LayerTests.cs +++ b/tests/Aetheria.Shared.Tests/LayerTests.cs @@ -6,6 +6,7 @@ using CultMath; using GameCult.Caching; using GameCult.Caching.MessagePack; using Xunit; +using static CultMath.math; using float2 = CultMath.float2; using float3 = CultMath.float3; using int2 = CultMath.int2; From d15052afaf9558f65a2e513291301c54996ac393 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 03:32:19 +0200 Subject: [PATCH 4/5] LayerTests: provenance lots for the fixture hulls (a0a85920 fails EachKindRidesItsLayer) Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/LayerTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/LayerTests.cs b/tests/Aetheria.Shared.Tests/LayerTests.cs index f6a61ae97..cb1bf4cf9 100644 --- a/tests/Aetheria.Shared.Tests/LayerTests.cs +++ b/tests/Aetheria.Shared.Tests/LayerTests.cs @@ -56,7 +56,9 @@ public sealed class LayerTests : IDisposable _cache.Upsert(new HullData { Name = "Station", HullType = HullType.Station, Shape = Solid(3), Durability = 1, Mass = 1000000 }); _cache.Upsert(new HullData { Name = "Turret", HullType = HullType.Turret, Shape = Solid(7), Durability = 1, Mass = 50 }); _cache.FlushAsync().Wait(); - var items = new ItemManager(_cache, new ProvenanceLedger(), Settings(), _ => { }); + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= 3; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(_cache, ledger, Settings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack { Radius = 0 }, new GalaxyZone { Name = "Layers", Owner = null }, null); return (items, zone); } From cbdecfd5786f319570235aa8f14c9fab31fafe9c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 04:24:43 +0200 Subject: [PATCH 5/5] Layering fixes: mine floater layer checked in the wiring verify; contact fixture orbitals carry Station/Turret hull types; grab point rides the floater layer Unity compile is verified before push; not yet built when committed. Co-Authored-By: Claude Opus 5.5 --- .../Scripts/Editor/DrawnPoseWiringVerify.cs | 3 +++ .../Scripts/ServerShared/Behaviors/Pickup.cs | 2 +- tests/Aetheria.Shared.Tests/ContactTests.cs | 13 ++++++++----- tests/Aetheria.Shared.Tests/PickupTests.cs | 19 ++++++++++++++++++- 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs b/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs index 3815ab27d..96aba2819 100644 --- a/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs +++ b/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs @@ -100,6 +100,7 @@ namespace Aetheria.EditorTools { var settings = AssetDatabase.LoadAssetAtPath("Assets/Resources/Settings.asset")?.GameplaySettings ?? throw new InvalidOperationException("No Settings.asset."); + settings.FloaterLayerHeight = -1.5f; // nonzero, so a presenter that drops the floater layer fails var wired = SessionState.GetString(Wiring, "").Split('|').Select(entry => entry.Split('=')).ToDictionary(pair => pair[0], pair => pair[1]); var pickupPrefab = AssetDatabase.LoadAssetAtPath(wired["SimpleCommodityPickup"])?.GetComponent(); @@ -178,6 +179,8 @@ namespace Aetheria.EditorTools want = mine.Body.Position + mineVelocity * lead; Require(Mathf.Abs(seen.x - want.x) < Tolerance && Mathf.Abs(seen.z - want.y) < Tolerance, $"MineInstance at lead {lead}: drawn x/z {seen.x},{seen.z}, the step plus velocity times lead {want.x},{want.y}."); + Require(Mathf.Abs(seen.y - DrawAhead.FloaterHeight(zone, settings, new float2(want.x, want.y), lead)) < Tolerance, + $"MineInstance y {seen.y} is not on the floater layer."); // The round's line is a function of the drawn time: it moves RoundSpeed * lead along its direction between leads. seen = projectile.transform.position; diff --git a/Assets/Scripts/ServerShared/Behaviors/Pickup.cs b/Assets/Scripts/ServerShared/Behaviors/Pickup.cs index 0e4747183..29cd9c383 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Pickup.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Pickup.cs @@ -167,5 +167,5 @@ public class Pickup : Behavior, IAlwaysUpdatedBehavior } private void Publish(GrabPhase phase, FloatingItem item, float duration) => - Entity.Capabilities.PublishGrab(new GrabEvent(item.Id, phase, float3(item.Body.Position.x, Entity.Position.y, item.Body.Position.y), duration)); + Entity.Capabilities.PublishGrab(new GrabEvent(item.Id, phase, float3(item.Body.Position.x, DrawAhead.FloaterHeight(Entity.Zone, Entity.ItemManager.GameplaySettings, item.Body.Position, 0), item.Body.Position.y), duration)); } diff --git a/tests/Aetheria.Shared.Tests/ContactTests.cs b/tests/Aetheria.Shared.Tests/ContactTests.cs index 2efc5431c..bccd2a90d 100644 --- a/tests/Aetheria.Shared.Tests/ContactTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactTests.cs @@ -98,9 +98,9 @@ public sealed class ContactTests : IDisposable var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true); _caches.Add(cache); cache.Upsert(new VerseGrammar { Revision = 1 }); - HullData Hull(string name, Shape shape, float mass) => new HullData + HullData Hull(string name, Shape shape, float mass, HullType type = HullType.Ship) => new HullData { - Name = name, HullType = HullType.Ship, Shape = shape, Durability = 1000000, Mass = mass, Armor = 0, + Name = name, HullType = type, Shape = shape, Durability = 1000000, Mass = mass, Armor = 0, Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } }; cache.Upsert(Hull("Solid", Solid(5, 5), 1000)); @@ -109,6 +109,9 @@ public sealed class ContactTests : IDisposable cache.Upsert(Hull("Mid", Solid(5, 5), 3000)); cache.Upsert(Hull("Ell", Ell(), 1000)); cache.Upsert(Hull("Longinus", ShippedLonginus(), 1000)); + // The orbital hulls carry their real types, so a filter by runtime kind and one by hull type are told apart. + cache.Upsert(Hull("Station", Solid(5, 5), 1000000, HullType.Station)); + cache.Upsert(Hull("Turret", Solid(5, 5), 1000, HullType.Turret)); cache.FlushAsync().Wait(); var ledger = new ProvenanceLedger(); @@ -390,9 +393,9 @@ public sealed class ContactTests : IDisposable { var lab = Build(); var ship = lab.Add("Solid", float3(0, 0, 0), float2(10, 0)); - var station = lab.AddOrbital("Heavy", float3(10, 0, 0), float2(0, 0)); + var station = lab.AddOrbital("Station", float3(10, 0, 0), float2(0, 0)); // Behind the ship and faster than it, so it closes on the ship as the station does. - var turret = lab.AddOrbital("Solid", float3(-10, 0, 0), float2(30, 0)); + var turret = lab.AddOrbital("Turret", float3(-10, 0, 0), float2(30, 0)); lab.Step(); Assert.Empty(lab.Events); Assert.Equal(float2(10, 0), ship.Velocity); @@ -408,7 +411,7 @@ public sealed class ContactTests : IDisposable var lab = Build(.5f); var a = lab.Add("Solid", float3(0, 0, 0), float2(10, 0)); var b = lab.Add("Solid", float3(10, 0, 0), float2(-10, 0)); - lab.AddOrbital("Heavy", float3(5, 0, 0), float2(0, 0)); + lab.AddOrbital("Station", float3(5, 0, 0), float2(0, 0)); lab.Step(); var contact = Assert.Single(lab.Events); diff --git a/tests/Aetheria.Shared.Tests/PickupTests.cs b/tests/Aetheria.Shared.Tests/PickupTests.cs index d88ed5e53..78e1d478a 100644 --- a/tests/Aetheria.Shared.Tests/PickupTests.cs +++ b/tests/Aetheria.Shared.Tests/PickupTests.cs @@ -74,7 +74,8 @@ public sealed class PickupTests : IDisposable TargetDetectionInfoThreshold = .1f, TargetGearInfoThreshold = .8f, FiringArc = 170, CommitHorizon = .5f, SchematicCellSize = 1f, UnaidedAccuracy = .05f, UnaidedTracking = 10f, UnaidedPrecision = 2f, AgentMinHitProbability = 0f, - LootDropProbability = 0, LootDropVelocity = 25, FloatingItemLifetime = lifetime + LootDropProbability = 0, LootDropVelocity = 25, FloatingItemLifetime = lifetime, + ShipLayerHeight = 3f, FloaterLayerHeight = -1.5f }; var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true); _caches.Add(cache); @@ -244,6 +245,22 @@ public sealed class PickupTests : IDisposable } } + // The beam ends on the loot where the loot is drawn: the field plus the floater layer, not the grabbing ship's plane + // (ruling orbitals-above-floaters-below). + [Fact] + public void AGrabPointRidesTheFloaterLayerNotTheShips() + { + using var bag = new CompositeDisposable(); + var lab = Build(Beam()); + var ship = Spawn(lab, float3(0, 0, 0)); + var item = Drift(lab, 40, 0); + var events = Watch(ship, bag); + ship.TractorCommand = 1; + for (var tick = 1; tick <= 150; tick++) lab.Zone.Update(1 / 60f); + Assert.NotEmpty(events); + Assert.All(events, e => Assert.Equal(lab.Zone.GetHeight(e.TargetPosition.xz, lab.Zone.ExactTime) - 1.5f, e.TargetPosition.y, 3)); + } + [Fact] public void GrabEventsNameTheFloatingItem() {