From 7392bc8fe17bf6f7d84c50b4e252558f47044eca Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 05:46:32 +0200 Subject: [PATCH 1/6] Ship contact is a simulation rule: Zone steps Contacts, Unity physics writes no Entity Contacts.Step exchanges normal velocity between touching hull discs (PostCollisionVelocity moved verbatim from ShieldManager) and publishes Zone.Contacted; ShieldManager presents it; HullCollider.OnCollisionEnter and ShieldManager's ship-on-ship branch are deleted. Not yet built or tested (verification follows). Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/HullCollider.cs | 11 +- Assets/Scripts/Gameplay/ShieldManager.cs | 42 ++-- Assets/Scripts/ServerShared/Contacts.cs | 83 +++++++ Assets/Scripts/ServerShared/Contacts.cs.meta | 11 + Assets/Scripts/ServerShared/Zone.cs | 6 + tests/Aetheria.Shared.Tests/ContactTests.cs | 236 +++++++++++++++++++ 6 files changed, 363 insertions(+), 26 deletions(-) create mode 100644 Assets/Scripts/ServerShared/Contacts.cs create mode 100644 Assets/Scripts/ServerShared/Contacts.cs.meta create mode 100644 tests/Aetheria.Shared.Tests/ContactTests.cs diff --git a/Assets/Scripts/Gameplay/HullCollider.cs b/Assets/Scripts/Gameplay/HullCollider.cs index 8d9275eb4..795de4755 100644 --- a/Assets/Scripts/Gameplay/HullCollider.cs +++ b/Assets/Scripts/Gameplay/HullCollider.cs @@ -1,17 +1,12 @@ using UnityEngine; -using CultMath.UnityBridge; // Cut 4 (docs/fire-control-cut.md): Hit, Splash, SendHit and SendSplash are deleted -- their last callers // (ConstantLaser, ConstantLightning, ConstantParticleWeapon, HitscanEffect) are gone too (see those // files and FireControl.Detonate, 12.4(b)'s renamed Splash). Nothing decides or applies damage through a Unity -// collider any more (R8): -// this component is now purely a physics surface for ship-on-ship collision impulse, not hit detection. +// collider any more (R8), and no collision callback writes an Entity (cut sim-contact): ship contact is +// Zone's, through Contacts.Step. This component holds the hull's collider for authoring, smokes and click +// picking, and writes nothing. public class HullCollider : MonoBehaviour { public Entity Entity { get; set; } - - private void OnCollisionEnter(Collision other) - { - Entity.Velocity += other.impulse.ToCultMath().xz / Time.deltaTime; - } } diff --git a/Assets/Scripts/Gameplay/ShieldManager.cs b/Assets/Scripts/Gameplay/ShieldManager.cs index 05a303e6d..46d0b36a5 100644 --- a/Assets/Scripts/Gameplay/ShieldManager.cs +++ b/Assets/Scripts/Gameplay/ShieldManager.cs @@ -5,6 +5,7 @@ using System.Linq; using CultMath; using CultMath.UnityBridge; using static CultMath.math; +using UniRx; using UnityEngine; public class ShieldManager : MonoBehaviour @@ -12,7 +13,29 @@ public class ShieldManager : MonoBehaviour public Prototype ShieldPrototype; public float CollisionHitDuration = 3; - public Entity Entity { get; set; } + private Entity _entity; + private IDisposable _contacts; + + // Ship-on-ship contact is Zone's (Contacts.Step); this shield only presents it. + public Entity Entity + { + get => _entity; + set + { + _contacts?.Dispose(); + _entity = value; + _contacts = value?.Zone.Contacted.Subscribe(Present); + } + } + + private void OnDestroy() => _contacts?.Dispose(); + + private void Present(ContactEvent contact) + { + if (contact.A != Entity && contact.B != Entity) return; + if (Entity.Shield != null && Entity.Shield.Item.Active.Value) + ShowHit(new Vector3(contact.Point.x, Entity.Position.y, contact.Point.y), CollisionHitDuration); + } private ShieldEnvelope _envelope; private bool _loggedMissingEnvelope; @@ -60,24 +83,7 @@ public class ShieldManager : MonoBehaviour } else Debug.Log("Shield collision occurred, but other collider isn't a shield or a grid object!"); - return; } - var contact = other.GetContact(0); - var normal = normalize(float2(contact.normal.x, contact.normal.z)); - var tangent = normal.Rotate(ItemRotation.CounterClockwise); - var v1n = dot(normal, Entity.Velocity); - var v1t = dot(tangent, Entity.Velocity); - var v2n = dot(normal, otherShield.Entity.Velocity); - //var v2t = dot(tangent, otherShield.Entity.Velocity); - - var v1np = PostCollisionVelocity(v1n, Entity.Mass, v2n, otherShield.Entity.Mass); - Entity.Velocity = tangent * v1t + normal * v1np; - if(Entity.Shield != null && Entity.Shield.Item.Active.Value) ShowHit(contact.point, CollisionHitDuration); - } - - private float PostCollisionVelocity(float v1, float m1, float v2, float m2) - { - return (v1 * (m1 - m2) + m2 * v2) / (m1 + m2); } public void ShowHit(Vector3 point, float duration) diff --git a/Assets/Scripts/ServerShared/Contacts.cs b/Assets/Scripts/ServerShared/Contacts.cs new file mode 100644 index 000000000..2389b1bac --- /dev/null +++ b/Assets/Scripts/ServerShared/Contacts.cs @@ -0,0 +1,83 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System.Linq; +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 +// two discs that touch while closing along the line between their centres exchange their normal velocity. +// Presentations read Zone.Contacted; no Unity collision callback writes an Entity. +public readonly struct ContactEvent +{ + public readonly Entity A; + public readonly Entity B; + public readonly float2 Point; + // Unit vector from A to B. + public readonly float2 Normal; + + public ContactEvent(Entity a, Entity b, float2 point, float2 normal) + { + A = a; + B = b; + Point = point; + Normal = normal; + } +} + +public static class Contacts +{ + // The farthest corner of any occupied hull cell from the centre of mass the schematic frame uses, in world + // units: a true bound on how far the hull reaches from Position (the half-diagonal of the bounding box is not, + // as FireControl.Detonate's comment records). A cell at (x, y) spans x +/- .5 and y +/- .5. + public static float Radius(Entity e) + { + var shape = e.HullData.Shape; + var com = shape.CenterOfMass; + var far = 0f; + foreach (var c in shape.Coordinates) + foreach (var (dx, dy) in new[] { (-.5f, -.5f), (.5f, -.5f), (-.5f, .5f), (.5f, .5f) }) + far = max(far, length(float2(c.x + dx, c.y + dy) - com)); + return far * e.ItemManager.GameplaySettings.SchematicCellSize; + } + + // The elastic exchange of one body's normal velocity: v1 and m1 the body's own, v2 and m2 the other's. + private static float PostCollisionVelocity(float v1, float m1, float v2, float m2) + { + return (v1 * (m1 - m2) + m2 * v2) / (m1 + m2); + } + + // For each unordered pair of the zone's entities, in list order: if their discs touch and are closing along + // the normal, each one's normal velocity is replaced (both from the velocities the pair had on entering) and + // its tangent component kept, and the contact is published. A docked entity (it has a Parent and takes its + // parent's pose) and an inactive one 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 radii = entities.Select(Radius).ToArray(); + for (var i = 0; i < entities.Length; i++) + for (var j = i + 1; j < entities.Length; j++) + { + var a = entities[i]; + var b = entities[j]; + var offset = b.Position.xz - a.Position.xz; + var distance = length(offset); + // Coincident centres have no normal. + if (distance <= 0f || distance >= radii[i] + radii[j]) continue; + var n = offset / distance; + var va = dot(n, a.Velocity); + var vb = dot(n, b.Velocity); + if (vb - va >= 0f) continue; + + var tangent = float2(-n.y, n.x); + var vaNew = PostCollisionVelocity(va, a.Mass, vb, b.Mass); + var vbNew = PostCollisionVelocity(vb, b.Mass, va, a.Mass); + a.Velocity = n * vaNew + tangent * dot(tangent, a.Velocity); + b.Velocity = n * vbNew + tangent * dot(tangent, b.Velocity); + zone.Contacted.OnNext(new ContactEvent(a, b, a.Position.xz + n * radii[i], n)); + } + } +} diff --git a/Assets/Scripts/ServerShared/Contacts.cs.meta b/Assets/Scripts/ServerShared/Contacts.cs.meta new file mode 100644 index 000000000..1cba80257 --- /dev/null +++ b/Assets/Scripts/ServerShared/Contacts.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7c1e5a3b9d4f4e2a8b6c0d1f2e3a4b5c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 97554a9ad..cdea43a9a 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -54,6 +54,8 @@ public class Zone public List PendingShots = new List(); public Subject ShotCommitted = new Subject(); public Subject ShotResolved = new Subject(); + // Contacts.Step publishes each hull-on-hull contact it resolves; presentations are the only readers. + public Subject Contacted = new Subject(); private int _nextShotId; public int NextShotId() => ++_nextShotId; @@ -241,6 +243,10 @@ public class Zone StepMines(deltaTime); StepClouds(deltaTime); + // Hull contact (cut sim-contact): after every entity has moved and the mines and clouds have taken + // their toll, so only hulls still in the zone touch, and before shots resolve against the result. + Contacts.Step(this); + // Cut 3: after every entity has had its chance to fire this tick, age and resolve the shots that // firing queued. A shot fired this tick with a flight time shorter than CommitHorizon commits and // resolves in this same call. diff --git a/tests/Aetheria.Shared.Tests/ContactTests.cs b/tests/Aetheria.Shared.Tests/ContactTests.cs new file mode 100644 index 000000000..f2b57e74a --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ContactTests.cs @@ -0,0 +1,236 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using UniRx; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; + +// Hull contact in the sim (aetheria-release cut sim-contact; ruling unity-presents-only-r3): Zone.Update steps +// Contacts, which exchanges the normal velocity of two touching hull discs and publishes the contact. 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 +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-contacts-" + Guid.NewGuid().ToString("N")); + private readonly List _caches = new List(); + + public ContactTests() => Directory.CreateDirectory(_root); + + public void Dispose() + { + foreach (var c in _caches) c.Dispose(); + Directory.Delete(_root, true); + } + + private static Shape Solid(int w, int h) + { + var shape = new Shape(w, h); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + return shape; + } + + private static Shape Ell() + { + var shape = new Shape(5, 5); + for (var i = 0; i < 5; i++) { shape[new int2(i, 0)] = true; shape[new int2(0, i)] = true; } + return shape; + } + + private static Shape ShippedLonginus() + { + var cache = new CultCache(TestCatalog.Registry()); + var store = new SingleFileMessagePackBackingStore(TestCatalog.Repo, true); + cache.AddBackingStore(store, AetheriaStores.CatalogTypes); + try { return cache.GetByName("Longinus").Shape; } + finally { cache.Dispose(); } + } + + private sealed class Lab + { + public ItemManager Items; + public Zone Zone; + public readonly List Events = new List(); + public int Lot = 1; + + public Ship Add(string hull, float3 at, float2 velocity) + { + var hullRef = Items.ItemData.RefOf(Items.ItemData.GetByName(hull)); + var ship = new Ship(Items, Zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = Lot++ }, new EntitySettings()); + Zone.Entities.Add(ship); + ship.Activate(); + ship.Position = at; + ship.Velocity = velocity; + return ship; + } + + public void Step() => Zone.Update(.01f); + } + + private Lab Build() + { + var 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(), + TargetDetectionInfoThreshold = .1f, TargetArmorInfoThreshold = .2f, TargetGearInfoThreshold = .8f, + FiringArc = 170, CommitHorizon = .5f, SchematicCellSize = 2f + }; + 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 + { + Name = name, HullType = HullType.Ship, 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)); + cache.Upsert(Hull("Heavy", Solid(5, 5), 1000000)); + cache.Upsert(Hull("Ell", Ell(), 1000)); + cache.Upsert(Hull("Longinus", ShippedLonginus(), 1000)); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= 50; 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 = "Contacts", Owner = null }, null); + var lab = new Lab { Items = items, Zone = zone }; + zone.Contacted.Subscribe(lab.Events.Add); + return lab; + } + + // The unit normal as the step saw it: from the first body's final position to the second's. + private static float2 NormalOf(Entity a, Entity b) => normalize(b.Position.xz - a.Position.xz); + + // The formula is the deleted ShieldManager.PostCollisionVelocity verbatim (cut sim-contact moves it, it does + // not change it): with equal masses a body ends at half the other's incoming normal velocity. + [Fact] + public void HeadOnEqualMassesTakeHalfTheOthersNormalVelocity() + { + var lab = Build(); + 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.Step(); + + var n = NormalOf(a, b); + Assert.Equal(-5f, dot(n, a.Velocity), 4); + Assert.Equal(5f, dot(n, b.Velocity), 4); + var contact = Assert.Single(lab.Events); + Assert.Equal(new[] { a, b }, new[] { contact.A, contact.B }); + Assert.Equal(1f, length(contact.Normal), 5); + Assert.Equal(n.x, contact.Normal.x, 5); + // The contact point sits on A's disc, towards B. + Assert.Equal(7.0711f, length(contact.Point - a.Position.xz), 2); + } + + [Fact] + public void GlancingContactKeepsTangent() + { + var lab = Build(); + var a = lab.Add("Solid", float3(0, 0, 0), float2(10, 0)); + var b = lab.Add("Solid", float3(7.07f, 0, 7.07f), float2(0, 0)); + lab.Step(); + + var n = NormalOf(a, b); + var t = float2(-n.y, n.x); + // A's incoming velocity (10, 0) has after the step the tangent component it had before, and so does B's (zero). + Assert.Equal(10f * t.x, dot(t, a.Velocity), 4); + Assert.Equal(0f, dot(t, b.Velocity), 4); + // Only the normal components changed: A handed half its incoming normal component 10 * n.x to B. + Assert.Equal(0f, dot(n, a.Velocity), 4); + Assert.Equal(5f * n.x, dot(n, b.Velocity), 4); + Assert.Single(lab.Events); + } + + [Fact] + public void OpeningPairsAreUntouched() + { + var lab = Build(); + var a = lab.Add("Solid", float3(0, 0, 0), float2(-5, 0)); + var b = lab.Add("Solid", float3(10, 0, 0), float2(5, 0)); + lab.Step(); + Assert.Equal(-5f, a.Velocity.x, 4); + Assert.Equal(5f, b.Velocity.x, 4); + Assert.Empty(lab.Events); + } + + [Fact] + public void OneEventPerContact() + { + var lab = Build(); + lab.Add("Solid", float3(0, 0, 0), float2(10, 0)); + lab.Add("Solid", float3(10, 0, 0), float2(-10, 0)); + lab.Step(); + Assert.Single(lab.Events); + // The exchange left the pair opening: the next step publishes nothing. + lab.Step(); + Assert.Single(lab.Events); + } + + [Fact] + public void ADistantPairNeverTouches() + { + var lab = Build(); + lab.Add("Solid", float3(0, 0, 0), float2(10, 0)); + lab.Add("Solid", float3(14.2f, 0, 0), float2(-10, 0)); + lab.Step(); + Assert.Empty(lab.Events); + } + + // Today's formula for a ship against a 1000x heavier body: the ship's normal velocity becomes + // (v1 (m1 - m2) + m2 v2) / (m1 + m2) = 10 * -999000 / 1001000, the heavy body's 10 * 1000 / 1001000. + [Fact] + public void MassRatioIsTodaysFormula() + { + var lab = Build(); + var ship = lab.Add("Solid", float3(0, 0, 0), float2(10, 0)); + var station = lab.Add("Heavy", float3(10, 0, 0), float2(0, 0)); + Assert.Equal(1000f, ship.Mass); + Assert.Equal(1000000f, station.Mass); + lab.Step(); + + var n = NormalOf(ship, station); + Assert.Equal(10f * -999000f / 1001000f, dot(n, ship.Velocity), 3); + Assert.Equal(10f * 1000f / 1001000f, dot(n, station.Velocity), 4); + } + + [Fact] + public void ADockedHullTakesNoPartInContact() + { + var lab = Build(); + var carrier = lab.Add("Solid", float3(0, 0, 0), float2(0, 0)); + var docked = lab.Add("Solid", float3(0, 0, 0), float2(0, 0)); + docked.SetParent(carrier); + lab.Step(); + Assert.Empty(lab.Events); + } + + [Theory] + [InlineData("Ell")] + [InlineData("Longinus")] + public void RadiusBoundsEveryCell(string hull) + { + var lab = Build(); + var ship = lab.Add(hull, float3(0, 0, 0), float2(0, 0)); + var shape = ship.HullData.Shape; + var com = shape.CenterOfMass; + var radius = Contacts.Radius(ship); + + var far = 0f; + foreach (var c in shape.Coordinates) + foreach (var (dx, dy) in new[] { (-.5f, -.5f), (.5f, -.5f), (-.5f, .5f), (.5f, .5f) }) + { + var d = length(float2(c.x + dx, c.y + dy) - com) * 2f; + Assert.True(d <= radius + 1e-4f, $"a corner of {hull} lies {d} from the centre of mass, past Radius {radius}"); + far = max(far, d); + } + Assert.Equal(radius, far, 3); + } +} From 7565458b4dc93705e0617a34a7b583e09c5177ed Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 06:05:48 +0200 Subject: [PATCH 2/6] ContactTests: the distant pair starts beyond the discs' reach after the tick's own movement The pair moved 0.1 each before the contact step and so closed inside the 14.14 bound; start them 15 apart. Builds; the first commit's only red test was this one. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ContactTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/ContactTests.cs b/tests/Aetheria.Shared.Tests/ContactTests.cs index f2b57e74a..feb11e317 100644 --- a/tests/Aetheria.Shared.Tests/ContactTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactTests.cs @@ -179,7 +179,7 @@ public sealed class ContactTests : IDisposable { var lab = Build(); lab.Add("Solid", float3(0, 0, 0), float2(10, 0)); - lab.Add("Solid", float3(14.2f, 0, 0), float2(-10, 0)); + lab.Add("Solid", float3(15f, 0, 0), float2(-10, 0)); lab.Step(); Assert.Empty(lab.Events); } From 8d7b90111a0c5423ce50a8b344e86c8c90cf3b06 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 06:32:58 +0200 Subject: [PATCH 3/6] ContactTests: the docked-hull test closes a third ship on the carrier, so excluding the docked hull is observable A docked hull shares its carrier's velocity, so the pair alone never closes; a visitor does expose a second contact. Builds. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ContactTests.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/ContactTests.cs b/tests/Aetheria.Shared.Tests/ContactTests.cs index feb11e317..ddc07d11c 100644 --- a/tests/Aetheria.Shared.Tests/ContactTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactTests.cs @@ -201,6 +201,8 @@ public sealed class ContactTests : IDisposable Assert.Equal(10f * 1000f / 1001000f, dot(n, station.Velocity), 4); } + // A docked hull rides its carrier's pose and velocity, so it is never a second body at the carrier's place: a + // ship closing on the carrier makes one contact, with the carrier. [Fact] public void ADockedHullTakesNoPartInContact() { @@ -208,8 +210,11 @@ public sealed class ContactTests : IDisposable var carrier = lab.Add("Solid", float3(0, 0, 0), float2(0, 0)); var docked = lab.Add("Solid", float3(0, 0, 0), float2(0, 0)); docked.SetParent(carrier); + var visitor = lab.Add("Solid", float3(10, 0, 0), float2(-10, 0)); lab.Step(); - Assert.Empty(lab.Events); + + var contact = Assert.Single(lab.Events); + Assert.Equal(new[] { carrier, visitor }, new[] { contact.A, contact.B }); } [Theory] From febda87ec528e76650c79cd7c2ec94baf9c37fec Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 06:33:28 +0200 Subject: [PATCH 4/6] ContactTests: the docked-hull visitor is the heavy hull, which keeps closing after its first exchange With equal masses the first exchange stops the visitor and a second contact with the docked hull cannot show. Builds. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ContactTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ContactTests.cs b/tests/Aetheria.Shared.Tests/ContactTests.cs index ddc07d11c..15c28119a 100644 --- a/tests/Aetheria.Shared.Tests/ContactTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactTests.cs @@ -202,7 +202,8 @@ public sealed class ContactTests : IDisposable } // A docked hull rides its carrier's pose and velocity, so it is never a second body at the carrier's place: a - // ship closing on the carrier makes one contact, with the carrier. + // ship closing on the carrier makes one contact, with the carrier. (The visitor is the heavy hull: it keeps + // closing after the first exchange, so a second contact with the docked hull would show.) [Fact] public void ADockedHullTakesNoPartInContact() { @@ -210,7 +211,7 @@ public sealed class ContactTests : IDisposable var carrier = lab.Add("Solid", float3(0, 0, 0), float2(0, 0)); var docked = lab.Add("Solid", float3(0, 0, 0), float2(0, 0)); docked.SetParent(carrier); - var visitor = lab.Add("Solid", float3(10, 0, 0), float2(-10, 0)); + var visitor = lab.Add("Heavy", float3(10, 0, 0), float2(-10, 0)); lab.Step(); var contact = Assert.Single(lab.Events); From 806641ae6297fc1b56dca3b624458be301a1a81d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 18:28:29 +0200 Subject: [PATCH 5/6] Ship contact is a restitution impulse along the normal while closing; e is GameplaySettings.ContactRestitution (.5) Ruling ship-bounce-restitution-setting: j = -(1+e)(v_rel.n)/(1/m1+1/m2), applied only while closing. The old exchange formula (PostCollisionVelocity) is deleted. ContactTests now pin momentum, frame shift, restitution, no impulse while separating, slow closing, tangent of both bodies, inactive hulls, the contact point radius and the three-body pile. Not yet built or run; verification follows. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Contacts.cs | 32 ++- Assets/Scripts/ServerShared/Settings.cs | 4 + tests/Aetheria.Shared.Tests/ContactTests.cs | 243 +++++++++++++++++--- 3 files changed, 235 insertions(+), 44 deletions(-) diff --git a/Assets/Scripts/ServerShared/Contacts.cs b/Assets/Scripts/ServerShared/Contacts.cs index 2389b1bac..f680faa5a 100644 --- a/Assets/Scripts/ServerShared/Contacts.cs +++ b/Assets/Scripts/ServerShared/Contacts.cs @@ -9,7 +9,10 @@ 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 -// two discs that touch while closing along the line between their centres exchange their normal velocity. +// 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 +// GameplaySettings.ContactRestitution. // Presentations read Zone.Contacted; no Unity collision callback writes an Entity. public readonly struct ContactEvent { @@ -44,16 +47,12 @@ public static class Contacts return far * e.ItemManager.GameplaySettings.SchematicCellSize; } - // The elastic exchange of one body's normal velocity: v1 and m1 the body's own, v2 and m2 the other's. - private static float PostCollisionVelocity(float v1, float m1, float v2, float m2) - { - return (v1 * (m1 - m2) + m2 * v2) / (m1 + m2); - } - // For each unordered pair of the zone's entities, in list order: if their discs touch and are closing along - // the normal, each one's normal velocity is replaced (both from the velocities the pair had on entering) and - // its tangent component kept, and the contact is published. A docked entity (it has a Parent and takes its - // parent's pose) and an inactive one are not hulls in the zone. No positional push-out. + // 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 + // total momentum does not. A docked entity (it has a Parent and takes its parent's pose) and an inactive one + // 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(); @@ -68,15 +67,12 @@ public static class Contacts // Coincident centres have no normal. if (distance <= 0f || distance >= radii[i] + radii[j]) continue; var n = offset / distance; - var va = dot(n, a.Velocity); - var vb = dot(n, b.Velocity); - if (vb - va >= 0f) continue; + var closing = dot(n, b.Velocity - a.Velocity); + if (closing >= 0f) continue; - var tangent = float2(-n.y, n.x); - var vaNew = PostCollisionVelocity(va, a.Mass, vb, b.Mass); - var vbNew = PostCollisionVelocity(vb, b.Mass, va, a.Mass); - a.Velocity = n * vaNew + tangent * dot(tangent, a.Velocity); - b.Velocity = n * vbNew + tangent * dot(tangent, b.Velocity); + var impulse = -(1f + a.ItemManager.GameplaySettings.ContactRestitution) * closing / (1f / a.Mass + 1f / b.Mass); + a.Velocity -= n * (impulse / a.Mass); + b.Velocity += n * (impulse / b.Mass); zone.Contacted.OnNext(new ContactEvent(a, b, a.Position.xz + n * radii[i], n)); } } diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index a983b97f3..fcc9858a2 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -268,6 +268,10 @@ public class GameplaySettings // (pSpread) to turn a hull's cell footprint into a real-world silhouette size at range. public float SchematicCellSize = 2f; + // Restitution of a hull striking a hull (Contacts.Step): the speed at which a pair separates along the contact + // normal is this times the speed at which it closed. 0 is perfectly inelastic, 1 perfectly elastic. + public float ContactRestitution = .5f; + // 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/tests/Aetheria.Shared.Tests/ContactTests.cs b/tests/Aetheria.Shared.Tests/ContactTests.cs index 15c28119a..a1800fc41 100644 --- a/tests/Aetheria.Shared.Tests/ContactTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactTests.cs @@ -12,8 +12,9 @@ using float2 = CultMath.float2; using float3 = CultMath.float3; using int2 = CultMath.int2; -// Hull contact in the sim (aetheria-release cut sim-contact; ruling unity-presents-only-r3): Zone.Update steps -// Contacts, which exchanges the normal velocity of two touching hull discs and publishes the contact. Every test +// 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 // 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 @@ -73,7 +74,7 @@ public sealed class ContactTests : IDisposable public void Step() => Zone.Update(.01f); } - private Lab Build() + private Lab Build(float restitution = .5f) { var settings = new GameplaySettings { @@ -81,7 +82,7 @@ public sealed class ContactTests : IDisposable Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, QualityPriceModifier = new ExponentialLerp(), TargetDetectionInfoThreshold = .1f, TargetArmorInfoThreshold = .2f, TargetGearInfoThreshold = .8f, - FiringArc = 170, CommitHorizon = .5f, SchematicCellSize = 2f + FiringArc = 170, CommitHorizon = .5f, SchematicCellSize = 2f, ContactRestitution = restitution }; var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true); _caches.Add(cache); @@ -93,6 +94,7 @@ public sealed class ContactTests : IDisposable }; cache.Upsert(Hull("Solid", Solid(5, 5), 1000)); cache.Upsert(Hull("Heavy", Solid(5, 5), 1000000)); + cache.Upsert(Hull("Big", Solid(7, 7), 1000)); cache.Upsert(Hull("Ell", Ell(), 1000)); cache.Upsert(Hull("Longinus", ShippedLonginus(), 1000)); cache.FlushAsync().Wait(); @@ -109,19 +111,120 @@ public sealed class ContactTests : IDisposable // The unit normal as the step saw it: from the first body's final position to the second's. private static float2 NormalOf(Entity a, Entity b) => normalize(b.Position.xz - a.Position.xz); - // The formula is the deleted ShieldManager.PostCollisionVelocity verbatim (cut sim-contact moves it, it does - // not change it): with equal masses a body ends at half the other's incoming normal velocity. - [Fact] - public void HeadOnEqualMassesTakeHalfTheOthersNormalVelocity() + private static float2 Momentum(params Entity[] es) { - var lab = Build(); + var p = float2(0, 0); + foreach (var e in es) p += e.Mass * e.Velocity; + return p; + } + + // An oblique contact between unequal masses with both ships moving; `shift` is added to both incoming + // velocities (a moving frame). + private (Ship a, Ship b, float2 closingBefore, Lab lab) Oblique(float e, float2 shift) + { + var lab = Build(e); + var va = float2(8, 3) + shift; + var vb = float2(-4, -2) + shift; + var a = lab.Add("Solid", float3(0, 0, 0), va); + var b = lab.Add("Mid", float3(7, 0, 7), vb); + lab.Step(); + return (a, b, vb - va, lab); + } + + // Restitution: the speed at which a pair separates along the normal is e times the speed at which it closed. + [Theory] + [InlineData(0f)] + [InlineData(.5f)] + [InlineData(1f)] + public void SeparatingNormalSpeedIsRestitutionTimesClosingSpeed(float e) + { + var (a, b, relBefore, lab) = Oblique(e, float2(0, 0)); + var n = NormalOf(a, b); + var closing = -dot(n, relBefore); + Assert.True(closing > 0f); + Assert.Equal(e * closing, dot(n, b.Velocity - a.Velocity), 3); + Assert.Single(lab.Events); + } + + // The same bounce at a closing speed far below and far above the others: restitution is the setting at any speed. + [Theory] + [InlineData(.5f, 2f)] + [InlineData(.5f, 40f)] + [InlineData(1f, 2f)] + [InlineData(1f, 40f)] + [InlineData(0f, 2f)] + public void RestitutionHoldsAtEveryClosingSpeed(float e, float speedEach) + { + var lab = Build(e); + var a = lab.Add("Solid", float3(0, 0, 0), float2(speedEach, 0)); + var b = lab.Add("Heavy", float3(10, 0, 0), float2(-speedEach, 0)); + lab.Step(); + var n = NormalOf(a, b); + Assert.Equal(e * 2f * speedEach * n.x, dot(n, b.Velocity - a.Velocity), 3); + Assert.Single(lab.Events); + } + + // Momentum is conserved for unequal masses, an oblique contact and both ships moving. + [Theory] + [InlineData(0f)] + [InlineData(.5f)] + [InlineData(1f)] + public void ContactConservesMomentum(float e) + { + var lab = Build(e); + var a = lab.Add("Solid", float3(0, 0, 0), float2(8, 3)); + var b = lab.Add("Mid", float3(7, 0, 7), float2(-4, -2)); + var before = Momentum(a, b); + lab.Step(); + Assert.Single(lab.Events); + var after = Momentum(a, b); + Assert.Equal(before.x, after.x, 1); + Assert.Equal(before.y, after.y, 1); + Assert.True(length(a.Velocity - float2(8, 3)) > 1f, "the contact moved the first ship"); + } + + // A frame moving with the pair changes nothing: add the same velocity to both and each outcome shifts by exactly it. + [Theory] + [InlineData(.5f)] + [InlineData(1f)] + public void OutcomeShiftsByTheFrameVelocity(float e) + { + var shift = float2(25, -40); + var (a0, b0, _, _) = Oblique(e, float2(0, 0)); + var (a1, b1, _, lab1) = Oblique(e, shift); + Assert.Single(lab1.Events); + Assert.Equal(a0.Velocity.x + shift.x, a1.Velocity.x, 2); + Assert.Equal(a0.Velocity.y + shift.y, a1.Velocity.y, 2); + Assert.Equal(b0.Velocity.x + shift.x, b1.Velocity.x, 2); + Assert.Equal(b0.Velocity.y + shift.y, b1.Velocity.y, 2); + } + + // Two equal ships in a formation at 100 drift together at a closing speed of 1: they bounce apart at e, and + // the convoy keeps its momentum. + [Fact] + public void FormationBumpKeepsTheConvoyMoving() + { + var lab = Build(.5f); + var a = lab.Add("Solid", float3(0, 0, 0), float2(101, 0)); + var b = lab.Add("Solid", float3(10, 0, 0), float2(100, 0)); + var before = Momentum(a, b); + lab.Step(); + Assert.Single(lab.Events); + Assert.Equal(before.x, Momentum(a, b).x, 1); + Assert.True(a.Velocity.x > 99f && b.Velocity.x > 99f); + } + + [Fact] + public void HeadOnEqualMassesAtFullRestitutionSwapNormalVelocity() + { + var lab = Build(1f); 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.Step(); var n = NormalOf(a, b); - Assert.Equal(-5f, dot(n, a.Velocity), 4); - Assert.Equal(5f, dot(n, b.Velocity), 4); + Assert.Equal(-10f, dot(n, a.Velocity), 3); + Assert.Equal(10f, dot(n, b.Velocity), 3); var contact = Assert.Single(lab.Events); Assert.Equal(new[] { a, b }, new[] { contact.A, contact.B }); Assert.Equal(1f, length(contact.Normal), 5); @@ -130,22 +233,32 @@ public sealed class ContactTests : IDisposable Assert.Equal(7.0711f, length(contact.Point - a.Position.xz), 2); } + // The point is on A's disc, at A's radius, not B's: a small hull A against a larger hull B. [Fact] - public void GlancingContactKeepsTangent() + public void ContactPointLiesOnTheFirstHullsDisc() { var lab = Build(); var a = lab.Add("Solid", float3(0, 0, 0), float2(10, 0)); - var b = lab.Add("Solid", float3(7.07f, 0, 7.07f), float2(0, 0)); + var b = lab.Add("Big", float3(12, 0, 0), float2(0, 0)); + lab.Step(); + Assert.True(Contacts.Radius(b) > Contacts.Radius(a) + 1f); + var contact = Assert.Single(lab.Events); + Assert.Equal(Contacts.Radius(a), length(contact.Point - a.Position.xz), 3); + } + + // Only the normal component changes: both bodies keep the tangent component they entered with. + [Fact] + public void BothBodiesKeepTheirTangentVelocity() + { + var lab = Build(); + var a = lab.Add("Solid", float3(0, 0, 0), float2(10, 0)); + var b = lab.Add("Mid", float3(7.07f, 0, 7.07f), float2(0, 3)); lab.Step(); var n = NormalOf(a, b); var t = float2(-n.y, n.x); - // A's incoming velocity (10, 0) has after the step the tangent component it had before, and so does B's (zero). - Assert.Equal(10f * t.x, dot(t, a.Velocity), 4); - Assert.Equal(0f, dot(t, b.Velocity), 4); - // Only the normal components changed: A handed half its incoming normal component 10 * n.x to B. - Assert.Equal(0f, dot(n, a.Velocity), 4); - Assert.Equal(5f * n.x, dot(n, b.Velocity), 4); + Assert.Equal(dot(t, float2(10, 0)), dot(t, a.Velocity), 4); + Assert.Equal(dot(t, float2(0, 3)), dot(t, b.Velocity), 4); Assert.Single(lab.Events); } @@ -161,6 +274,24 @@ public sealed class ContactTests : IDisposable Assert.Empty(lab.Events); } + // A pair that has separated is not bounced again: a heavy ship catching a light one is thrown clear by one + // contact, and the next ticks (the hulls still overlap, now opening) change and publish nothing. + [Fact] + public void NoImpulseWhileSeparating() + { + var lab = Build(.5f); + var a = lab.Add("Heavy", float3(0, 0, 0), float2(110, 0)); + var b = lab.Add("Solid", float3(10, 0, 0), float2(100, 0)); + lab.Step(); + var n = NormalOf(a, b); + Assert.True(dot(n, b.Velocity - a.Velocity) > 0f, "the bounce left the pair opening"); + var (va, vb) = (a.Velocity, b.Velocity); + for (var i = 0; i < 5; i++) lab.Step(); + Assert.Single(lab.Events); + Assert.Equal(va.x, a.Velocity.x, 3); + Assert.Equal(vb.x, b.Velocity.x, 3); + } + [Fact] public void OneEventPerContact() { @@ -169,7 +300,31 @@ public sealed class ContactTests : IDisposable lab.Add("Solid", float3(10, 0, 0), float2(-10, 0)); lab.Step(); Assert.Single(lab.Events); - // The exchange left the pair opening: the next step publishes nothing. + // The bounce left the pair opening: the next step publishes nothing. + lab.Step(); + Assert.Single(lab.Events); + } + + // A pair closing slowly still touches: there is no speed below which hulls pass through each other. + [Fact] + public void SlowClosingStillBounces() + { + var lab = Build(.5f); + var a = lab.Add("Solid", float3(0, 0, 0), float2(2, 0)); + var b = lab.Add("Solid", float3(10, 0, 0), float2(-2, 0)); + lab.Step(); + var n = NormalOf(a, b); + Assert.Single(lab.Events); + Assert.Equal(.5f * 4f * n.x, dot(n, b.Velocity - a.Velocity), 3); + } + + // The tiniest closing speed still bounces too. + [Fact] + public void BarelyClosingStillBounces() + { + var lab = Build(1f); + lab.Add("Solid", float3(0, 0, 0), float2(.1f, 0)); + lab.Add("Solid", float3(10, 0, 0), float2(0, 0)); lab.Step(); Assert.Single(lab.Events); } @@ -184,12 +339,12 @@ public sealed class ContactTests : IDisposable Assert.Empty(lab.Events); } - // Today's formula for a ship against a 1000x heavier body: the ship's normal velocity becomes - // (v1 (m1 - m2) + m2 v2) / (m1 + m2) = 10 * -999000 / 1001000, the heavy body's 10 * 1000 / 1001000. + // A ship against a 1000x heavier body: the heavy body 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 MassRatioIsTodaysFormula() + public void AShipAgainstAStationRebounds() { - var lab = Build(); + 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)); Assert.Equal(1000f, ship.Mass); @@ -197,8 +352,44 @@ public sealed class ContactTests : IDisposable lab.Step(); var n = NormalOf(ship, station); - Assert.Equal(10f * -999000f / 1001000f, dot(n, ship.Velocity), 3); - Assert.Equal(10f * 1000f / 1001000f, dot(n, station.Velocity), 4); + var closing = 10f * n.x; + Assert.Equal(1.5f * closing * 1000f / 1001000f, dot(n, station.Velocity), 4); + Assert.Equal(closing - 1.5f * closing * 1000000f / 1001000f, dot(n, ship.Velocity), 3); + } + + // An inactive hull is not in the zone: a ship flying through it is untouched. + [Fact] + public void AnInactiveHullTakesNoPartInContact() + { + var lab = Build(); + var a = lab.Add("Solid", float3(0, 0, 0), float2(10, 0)); + var b = lab.Add("Solid", float3(10, 0, 0), float2(-10, 0)); + b.Deactivate(); + lab.Step(); + Assert.Empty(lab.Events); + Assert.Equal(10f, a.Velocity.x, 4); + Assert.Equal(-10f, b.Velocity.x, 4); + } + + // 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. + [Fact] + public void ThreeBodyContactConservesMomentumInEitherListOrder() + { + foreach (var reversed in new[] { false, true }) + { + var lab = Build(.5f); + var specs = new[] { ("Solid", float3(0, 0, 0), float2(10, 0)), ("Mid", float3(10, 0, 0), float2(0, 0)), ("Solid", float3(20, 0, 0), float2(-10, 0)) }; + var ships = new Ship[3]; + foreach (var i in reversed ? new[] { 2, 1, 0 } : new[] { 0, 1, 2 }) + ships[i] = lab.Add(specs[i].Item1, specs[i].Item2, specs[i].Item3); + lab.Step(); + Assert.Equal(2, lab.Events.Count); + var p = Momentum(ships); + Assert.Equal(0f, p.x, 1); + Assert.Equal(0f, p.y, 1); + } } // A docked hull rides its carrier's pose and velocity, so it is never a second body at the carrier's place: a From fdcd5298b11cee9afc147aa1d43e6079d5263ec4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 18:44:39 +0200 Subject: [PATCH 6/6] ContactTests: add the 3000-mass Mid hull the unequal-mass tests use The previous commit's ContactTests did not build a Mid hull, so ten tests failed on the missing hull (full run at 806641ae: 1481 passed, 10 failed). The commit builds; those ten tests are red until this one. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ContactTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Aetheria.Shared.Tests/ContactTests.cs b/tests/Aetheria.Shared.Tests/ContactTests.cs index a1800fc41..b139edea7 100644 --- a/tests/Aetheria.Shared.Tests/ContactTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactTests.cs @@ -95,6 +95,7 @@ public sealed class ContactTests : IDisposable cache.Upsert(Hull("Solid", Solid(5, 5), 1000)); cache.Upsert(Hull("Heavy", Solid(5, 5), 1000000)); cache.Upsert(Hull("Big", Solid(7, 7), 1000)); + cache.Upsert(Hull("Mid", Solid(5, 5), 3000)); cache.Upsert(Hull("Ell", Ell(), 1000)); cache.Upsert(Hull("Longinus", ShippedLonginus(), 1000)); cache.FlushAsync().Wait();